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

@@ -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);