From a17083309df8daa79d19038a0bf30185525a2a22 Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Mon, 21 Sep 2026 14:16:32 -0400 Subject: [PATCH] Begin milestone 3 age persistence --- README.md | 12 +- docs/PORTING.md | 32 ++++- gradle.properties | 4 +- .../rivencraft/book/AgeDefinition.java | 27 ++++ .../rivencraft/book/AgeRegistrySavedData.java | 127 ++++++++++++++++++ .../rivencraft/book/VanillaAgeResolver.java | 3 + .../rivencraft/gametest/LinkingGameTests.java | 27 ++++ 7 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java create mode 100644 src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java diff --git a/README.md b/README.md index 2cf63b8..256fb63 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,17 @@ The built mod is written to `build/libs/`. ## Versioning -Rivencraft versions use `x.y.z`: +Rivencraft versions use `x.y.z.b`: - `x` changes for a major public release. - `y` changes when a development milestone is completed. -- `z` changes for iterations within the current milestone and for bug fixes. +- `z` changes for each release slice within the current milestone. +- `b` changes for bug fixes and additional work within that release slice. -The patch component resets to zero when a milestone is completed. For example, -milestone 2 development follows `0.1.1`, `0.1.2`, and so on; completing that -milestone produces `0.2.0`. +The build component resets to zero when a new release slice begins. Completing +a milestone increments `y` and resets both `z` and `b` to zero. For example, +milestone 2 development can progress from `0.1.1.0` to `0.1.1.1`, then +`0.1.2.0`; completing the milestone produces `0.2.0.0`. ## Porting approach diff --git a/docs/PORTING.md b/docs/PORTING.md index ae5170c..69f809b 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -12,10 +12,20 @@ split into independently testable milestones. 2. **Books and linking** — descriptive/linking book data components, lectern-like interaction, safe teleportation, and return links. **Complete.** 3. **Ages** — dimension definitions backed by data packs, deterministic seeds, - lifecycle management, and persistence. + lifecycle management, and persistence. Make the legacy-style animated + linking panel the built-in destination preview. Provide optional + Create/Ponder and Immersive Portals integrations as alternative rotating- + snapshot renderers. The configurable preview modes are `AUTO`, + `IMMERSIVE_PORTALS`, `PONDER`, and `LEGACY`; `AUTO` prefers Immersive + Portals when it is installed, then Create/Ponder, and finally the built-in + legacy animation. 4. **Writing system** — pages, symbols, notebooks, desks, and book assembly. 5. **Age grammar** — translate symbol combinations into modern biome, noise, - dimension-type, and worldgen configuration. + dimension-type, and worldgen configuration. Optional integrations with + TerraBlender provide composable biome-region generation, while Serene + Seasons and Ecliptic Seasons allow Age generation to participate in their + respective seasonal systems. None of these integrations are required for + Rivencraft's core world generation. 6. **Instability** — deterministic validation and effects with server-safe behavior. 7. **Structures and polish** — libraries, crystals, portals, recipes, models, @@ -174,20 +184,28 @@ Return Link only for that initial arrival. Version 0.2.8 makes Return Link creation a server setting: `ON_CREATION` (default), `ALWAYS`, or `DISABLED`. +Version 0.2.9.0 begins Milestone 3 with a server-owned, world-persistent Age +registry. Each Age UUID resolves exactly once to an immutable random seed, +generation profile, generation-version marker, canonical name, and creation +time. The temporary vanilla-dimension adapter remains in place until runtime +Age dimensions are introduced by a later release slice. + The preview code is separated behind a renderer interface and uses an isolated Ponder level populated from server-authoritative destination snapshots. ## Version policy -Versions follow `x.y.z`: +Versions follow `x.y.z.b`: - `x`: major release point. - `y`: number of completed milestones. -- `z`: iteration within the active milestone, including bug-fix releases. +- `z`: release slice within the active milestone. +- `b`: bug-fix and additional-work iteration within that release slice. -Starting work on a milestone increments `z`. Completing it increments `y` and -resets `z` to zero. The first milestone-2 slice is therefore `0.1.1`, and -milestone 2 completion will be `0.2.0`. +Starting a release slice increments `z` and resets `b` to zero. Completing a +milestone increments `y` and resets both `z` and `b` to zero. The first +milestone-2 slice is therefore `0.1.1.0`; a fix on that slice is `0.1.1.1`; +and milestone 2 completion is `0.2.0.0`. ## Milestone 1 acceptance record diff --git a/gradle.properties b/gradle.properties index 3691096..9ab1afa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -32,8 +32,8 @@ mod_id=rivencraft 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. See https://semver.org/ -mod_version=0.2.8 +# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. +mod_version=0.2.9.0 # 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 new file mode 100644 index 0000000..365d41f --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java @@ -0,0 +1,27 @@ +package net.dnorberg.rivencraft.book; + +import java.util.Objects; +import java.util.UUID; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.resources.ResourceLocation; + +/** Immutable, server-owned generation definition for one Age. */ +public record AgeDefinition( + UUID id, + String name, + long seed, + ResourceLocation generationProfile, + int generationVersion, + long createdGameTime +) { + public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath( + Rivencraft.MOD_ID, "random_vanilla" + ); + public static final int CURRENT_GENERATION_VERSION = 1; + + public AgeDefinition { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(generationProfile, "generationProfile"); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java new file mode 100644 index 0000000..bb7f504 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java @@ -0,0 +1,127 @@ +package net.dnorberg.rivencraft.book; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.world.level.saveddata.SavedData; + +/** Server-wide persistent mapping from stable book UUIDs to immutable Age definitions. */ +public final class AgeRegistrySavedData extends SavedData { + private static final String DATA_NAME = "rivencraft_ages"; + private static final String AGES_TAG = "Ages"; + private static final String ID_TAG = "Id"; + private static final String NAME_TAG = "Name"; + private static final String SEED_TAG = "Seed"; + private static final String PROFILE_TAG = "GenerationProfile"; + private static final String VERSION_TAG = "GenerationVersion"; + private static final String CREATED_TIME_TAG = "CreatedGameTime"; + private static final Factory FACTORY = new Factory<>( + AgeRegistrySavedData::new, + AgeRegistrySavedData::load, + DataFixTypes.SAVED_DATA_MAP_DATA + ); + + private final Map ages = new HashMap<>(); + + public static AgeRegistrySavedData get(ServerLevel level) { + return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME); + } + + /** Resolves a book identity, creating its deterministic random definition exactly once. */ + public static AgeDefinition resolveOrCreate(ServerLevel level, AgeReference reference) { + ServerLevel overworld = level.getServer().overworld(); + return get(overworld).getOrCreate(reference, overworld.getSeed(), overworld.getGameTime()); + } + + public Optional find(UUID ageId) { + return Optional.ofNullable(ages.get(ageId)); + } + + private AgeDefinition getOrCreate(AgeReference reference, long worldSeed, long createdGameTime) { + AgeDefinition existing = ages.get(reference.id()); + if (existing != null) { + return existing; + } + + AgeDefinition created = new AgeDefinition( + reference.id(), + normalizedName(reference.name()), + ageSeed(worldSeed, reference.id()), + AgeDefinition.RANDOM_VANILLA_PROFILE, + AgeDefinition.CURRENT_GENERATION_VERSION, + createdGameTime + ); + ages.put(created.id(), created); + setDirty(); + return created; + } + + private static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) { + AgeRegistrySavedData data = new AgeRegistrySavedData(); + ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND); + for (int index = 0; index < serializedAges.size(); index++) { + CompoundTag entry = serializedAges.getCompound(index); + if (!entry.hasUUID(ID_TAG)) { + continue; + } + ResourceLocation profile = ResourceLocation.tryParse(entry.getString(PROFILE_TAG)); + if (profile == null) { + profile = AgeDefinition.RANDOM_VANILLA_PROFILE; + } + UUID id = entry.getUUID(ID_TAG); + data.ages.put(id, new AgeDefinition( + id, + normalizedName(entry.getString(NAME_TAG)), + entry.getLong(SEED_TAG), + profile, + entry.contains(VERSION_TAG) ? entry.getInt(VERSION_TAG) : 1, + entry.getLong(CREATED_TIME_TAG) + )); + } + return data; + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + ListTag serializedAges = new ListTag(); + ages.values().stream() + .sorted((left, right) -> left.id().compareTo(right.id())) + .forEach(age -> { + CompoundTag entry = new CompoundTag(); + entry.putUUID(ID_TAG, age.id()); + entry.putString(NAME_TAG, age.name()); + entry.putLong(SEED_TAG, age.seed()); + entry.putString(PROFILE_TAG, age.generationProfile().toString()); + entry.putInt(VERSION_TAG, age.generationVersion()); + entry.putLong(CREATED_TIME_TAG, age.createdGameTime()); + serializedAges.add(entry); + }); + tag.put(AGES_TAG, serializedAges); + return tag; + } + + private static String normalizedName(String name) { + String normalized = name.strip(); + return normalized.isEmpty() ? "Unnamed Age" : normalized; + } + + private static long ageSeed(long worldSeed, UUID ageId) { + long value = worldSeed ^ ageId.getMostSignificantBits(); + value = mix64(value + ageId.getLeastSignificantBits()); + return mix64(value ^ 0x524956454E435241L); + } + + /** SplitMix64 finalizer, used only to derive a stable seed from world and Age identities. */ + private static long mix64(long value) { + value = (value ^ (value >>> 30)) * 0xBF58476D1CE4E5B9L; + value = (value ^ (value >>> 27)) * 0x94D049BB133111EBL; + return value ^ (value >>> 31); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/VanillaAgeResolver.java b/src/main/java/net/dnorberg/rivencraft/book/VanillaAgeResolver.java index 0d44dfd..57bbb02 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/VanillaAgeResolver.java +++ b/src/main/java/net/dnorberg/rivencraft/book/VanillaAgeResolver.java @@ -32,6 +32,9 @@ public final class VanillaAgeResolver { } public static Optional resolve(ServerPlayer player, AgeReference age) { + // Milestone 3 owns Age identity and generation data even while this + // temporary adapter continues to provide a vanilla travel destination. + AgeRegistrySavedData.resolveOrCreate(player.serverLevel(), age); ResourceKey dimension = VANILLA_DIMENSIONS.get(Math.floorMod(age.id().hashCode(), VANILLA_DIMENSIONS.size())); ServerLevel level = player.getServer().getLevel(dimension); if (level == null) { diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index 1d3e4f2..ea5a467 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -1,6 +1,9 @@ package net.dnorberg.rivencraft.gametest; import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.book.AgeDefinition; +import net.dnorberg.rivencraft.book.AgeReference; +import net.dnorberg.rivencraft.book.AgeRegistrySavedData; import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.LinkingService; import net.dnorberg.rivencraft.book.AgeVisitSavedData; @@ -75,6 +78,30 @@ public final class LinkingGameTests { helper.succeed(); } + @GameTest(template = "empty") + public static void ageDefinitionIsCreatedOnceWithDeterministicData(GameTestHelper helper) { + UUID ageId = UUID.randomUUID(); + AgeReference reference = new AgeReference(ageId, "Test Age"); + + AgeDefinition first = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(), reference); + AgeDefinition second = AgeRegistrySavedData.resolveOrCreate( + helper.getLevel(), new AgeReference(ageId, "A later book name") + ); + AgeDefinition another = AgeRegistrySavedData.resolveOrCreate( + helper.getLevel(), new AgeReference(UUID.randomUUID(), "Another Age") + ); + + 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.generationProfile(), AgeDefinition.RANDOM_VANILLA_PROFILE, + "A page-free book must use the random vanilla generation profile"); + helper.assertValueEqual(first.generationVersion(), AgeDefinition.CURRENT_GENERATION_VERSION, + "New Ages must record the current generation algorithm version"); + helper.assertTrue(first.seed() != another.seed(), "Distinct Age UUIDs must receive distinct random seeds"); + helper.succeed(); + } + @GameTest(template = "empty") public static void travelUsesLeafyDestinationWithoutFloor(GameTestHelper helper) { helper.setBlock(ORIGIN.below(), Blocks.AIR);