From 3242eb8192c697cdcca968a1df24b4b86c55e074 Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Mon, 21 Sep 2026 15:07:41 -0400 Subject: [PATCH] Name Age save folders after player titles --- README.md | 4 +++ docs/PORTING.md | 5 ++++ gradle.properties | 2 +- .../rivencraft/book/AgeDefinition.java | 22 +++++++++++++++ .../rivencraft/book/AgeRegistrySavedData.java | 28 +++++++++++++++---- .../rivencraft/gametest/LinkingGameTests.java | 12 ++++++-- 6 files changed, 65 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8def5ee..2fb8d16 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ available, the platform is generated at Y=63. Reopen the book and click its prev Copies of the written book continue to resolve to the same persistent Age across server restarts. +New Age dimension folders use a Minecraft-safe lowercase form of the assigned +name. Conflicts receive incrementing suffixes such as `_2` and `_3`; existing +Age folders are never renamed. + Linking creates a bound Return Link to the exact source position and orientation according to the configured Return Link policy. diff --git a/docs/PORTING.md b/docs/PORTING.md index b560677..55736e3 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -210,6 +210,11 @@ Version 0.2.10.4 restores Mystcraft Legacy's standard linking sound for both Linking and Descriptive Books. Successful travel plays the sound at its source and destination with the legacy volume and pitch variation. +Version 0.2.10.5 derives each new Age's saved dimension folder from its +player-assigned name. Names are converted to safe lowercase resource paths, +and folder conflicts receive `_2`, `_3`, and later numeric suffixes. Existing +UUID-based dimension folders remain unchanged for save compatibility. + The preview code is separated behind a renderer interface and uses an isolated Ponder level populated from server-authoritative destination snapshots. diff --git a/gradle.properties b/gradle.properties index c75391c..f7e98e3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,7 +35,7 @@ mod_name=Rivencraft # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-3.0-or-later # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.2.10.4 +mod_version=0.2.10.5 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java index a2c679c..96337c5 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java @@ -33,6 +33,28 @@ public record AgeDefinition( return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + ageId); } + public static ResourceLocation namedDimensionId(String ageName) { + StringBuilder pathName = new StringBuilder(); + boolean lastWasSeparator = false; + for (int index = 0; index < ageName.length(); index++) { + char character = Character.toLowerCase(ageName.charAt(index)); + if (character >= 'a' && character <= 'z' || character >= '0' && character <= '9') { + pathName.append(character); + lastWasSeparator = false; + } else if (!lastWasSeparator && !pathName.isEmpty()) { + pathName.append('_'); + lastWasSeparator = true; + } + } + while (!pathName.isEmpty() && pathName.charAt(pathName.length() - 1) == '_') { + pathName.deleteCharAt(pathName.length() - 1); + } + if (pathName.isEmpty()) { + pathName.append("unnamed_age"); + } + return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + pathName); + } + public AgeDefinition withSpawn(BlockPos spawn) { return new AgeDefinition(id, name, seed, generationProfile, generationVersion, createdGameTime, dimensionId, spawn.immutable()); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java index c499a44..c4fcaeb 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java @@ -71,14 +71,16 @@ public final class AgeRegistrySavedData extends SavedData { return existing; } + String name = normalizedName(reference.name()); + ResourceLocation dimensionId = availableDimensionId(name); AgeDefinition created = new AgeDefinition( reference.id(), - normalizedName(reference.name()), - ageSeed(worldSeed, reference.id()), + name, + ageSeed(worldSeed, dimensionId), AgeDefinition.RANDOM_VANILLA_PROFILE, AgeDefinition.CURRENT_GENERATION_VERSION, createdGameTime, - AgeDefinition.dimensionId(reference.id()), + dimensionId, null ); ages.put(created.id(), created); @@ -150,9 +152,25 @@ public final class AgeRegistrySavedData extends SavedData { return normalized.isEmpty() ? "Unnamed Age" : normalized; } - private static long ageSeed(long worldSeed, UUID ageId) { + private ResourceLocation availableDimensionId(String ageName) { + ResourceLocation base = AgeDefinition.namedDimensionId(ageName); + ResourceLocation candidate = base; + int suffix = 2; + while (dimensionIdInUse(candidate)) { + candidate = ResourceLocation.fromNamespaceAndPath( + base.getNamespace(), base.getPath() + "_" + suffix++ + ); + } + return candidate; + } + + private boolean dimensionIdInUse(ResourceLocation candidate) { + return ages.values().stream().anyMatch(age -> age.dimensionId().equals(candidate)); + } + + private static long ageSeed(long worldSeed, ResourceLocation dimensionId) { // Dynamic Dimensions AR derives each level seed from the primary world // seed and the dimension key. Persist the same value as Age metadata. - return worldSeed + AgeDefinition.dimensionId(ageId).hashCode(); + return worldSeed + dimensionId.hashCode(); } } diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index 0cb2a5c..337a726 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -82,7 +82,8 @@ public final class LinkingGameTests { @GameTest(template = "empty") public static void ageDefinitionIsCreatedOnceWithDeterministicData(GameTestHelper helper) { UUID ageId = UUID.randomUUID(); - AgeReference reference = new AgeReference(ageId, "Test Age"); + String requestedName = "Test Age " + ageId; + AgeReference reference = new AgeReference(ageId, requestedName); AgeDefinition first = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(), reference); AgeDefinition second = AgeRegistrySavedData.resolveOrCreate( @@ -91,10 +92,17 @@ public final class LinkingGameTests { AgeDefinition another = AgeRegistrySavedData.resolveOrCreate( helper.getLevel(), new AgeReference(UUID.randomUUID(), "Another Age") ); + AgeDefinition conflicting = AgeRegistrySavedData.resolveOrCreate( + helper.getLevel(), new AgeReference(UUID.randomUUID(), requestedName) + ); helper.assertValueEqual(second, first, "An existing Age UUID must retain its original immutable generation definition"); - helper.assertValueEqual(first.name(), "Test Age", "The first book name must become the canonical Age name"); + helper.assertValueEqual(first.name(), requestedName, "The first book name must become the canonical Age name"); + helper.assertValueEqual(first.dimensionId(), AgeDefinition.namedDimensionId(requestedName), + "A new Age dimension folder must derive from the player-assigned name"); + helper.assertValueEqual(conflicting.dimensionId().getPath(), first.dimensionId().getPath() + "_2", + "A conflicting Age dimension folder must receive an incrementing suffix"); helper.assertValueEqual(first.generationProfile(), AgeDefinition.RANDOM_VANILLA_PROFILE, "A page-free book must use the random vanilla generation profile"); helper.assertValueEqual(first.generationVersion(), AgeDefinition.CURRENT_GENERATION_VERSION,