Begin milestone 3 age persistence
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-21 14:16:32 -04:00
1 parent b1d563fb84
commit a17083309d
7 files changed
+218 -14

No files matched your search

+7 -5
View File
@@ -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
+25 -7
View File
@@ -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
+2 -2
View File
@@ -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
@@ -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");
}
}
@@ -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<AgeRegistrySavedData> FACTORY = new Factory<>(
AgeRegistrySavedData::new,
AgeRegistrySavedData::load,
DataFixTypes.SAVED_DATA_MAP_DATA
);
private final Map<UUID, AgeDefinition> 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<AgeDefinition> 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);
}
}
@@ -32,6 +32,9 @@ public final class VanillaAgeResolver {
}
public static Optional<LinkTarget> 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<Level> dimension = VANILLA_DIMENSIONS.get(Math.floorMod(age.id().hashCode(), VANILLA_DIMENSIONS.size()));
ServerLevel level = player.getServer().getLevel(dimension);
if (level == null) {
@@ -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);