Compare commits

...
2 Commits
Author SHA1 Message Date
dnorberg 954dd7097b Complete Milestone 3 documentation (#12)
Build / build (push) Canceled after 0s
2026-09-23 05:51:59 -04:00
dnorberg 1937a8c71d Add Age migration regression coverage (#11)
Build / build (push) Canceled after 0s
2026-09-23 05:41:38 -04:00
7 changed files with 305 additions and 14 deletions

No files matched your search

+8 -4
View File
@@ -4,10 +4,10 @@ Rivencraft is a clean, incremental port of the ideas and gameplay of
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
1.21.1 on NeoForge.
The bootstrap and books/linking milestones are complete. The project currently
provides functional Linking Books and randomly seeded, persistent vanilla-like
Ages created from page-free Descriptive Books. Symbols, instability, and the
full writing mechanics have not been ported yet.
The bootstrap, books/linking, and Ages milestones are complete. The project
currently provides functional Linking Books and randomly seeded, persistent
vanilla-like Ages created from page-free Descriptive Books. Symbols,
instability, and the full writing mechanics have not been ported yet.
## Linking Book
@@ -60,6 +60,10 @@ according to the configured Return Link policy.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
Age creation, persistence, lifecycle commands, configuration, backups, and
administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
Data-pack runtime profiles are documented in
[docs/AGE_PROFILES.md](docs/AGE_PROFILES.md).
## Requirements
+71 -1
View File
@@ -1,9 +1,35 @@
# Age administration
# Age creation and administration
## Creation
A page-free Descriptive Book creates an Age when the player assigns a name and
presses **Create**. Rivencraft immediately assigns a UUID, derives a unique
Minecraft-safe dimension identifier from the requested name, rolls a seed and
traits, and persists that definition before asking Dynamic Dimensions to create
the runtime world. Names that normalize to the same identifier receive stable
suffixes such as `_2` and `_3`.
The selected terrain profile, biome distribution, lighting, weather, celestial
period, celestial angle, and phase are deterministic and become part of the
saved definition. A five-by-five cobblestone platform with clear headroom is
prepared at a safe arrival point. The completed book and every copy carrying
the same UUID resolve to that definition.
Creating an Age can take several ticks. The book cannot be reopened while its
creation request is active. If creation is interrupted after the definition is
saved, using the book again resumes from the same immutable definition instead
of rolling a replacement.
## Persistence
Rivencraft owns each Age definition for the lifetime of a world. A definition
contains the Age UUID, canonical name, seed, generation version, runtime
dimension identifier, spawn, and deterministic traits. Definitions are stored
in the Overworld saved data and are never rerolled during ordinary loading.
Runtime terrain and entity data remain in the normal world dimension storage
managed by Minecraft and Dynamic Dimensions. Back up the complete world folder
while the server is stopped; copying only a dimension folder does not preserve
the authoritative Age registry.
## Lifecycle
@@ -37,3 +63,47 @@ The commands require permission level 2.
Opening a Descriptive Book for an unloaded Age loads it again from its saved
definition.
## Configuration
Rivencraft writes gameplay settings to the world/server configuration managed
by NeoForge. `descriptive_books.return_link_mode` accepts:
- `ON_CREATION` (default): create one Return Link the first time a player
enters each Age.
- `ALWAYS`: create a Return Link after every successful Descriptive Book trip.
- `DISABLED`: never create Return Links automatically.
Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode`
setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes
only presentation and cannot change server-side Age generation or travel
validation. See [PREVIEW_COMPATIBILITY.md](PREVIEW_COMPATIBILITY.md) for the
selection matrix and integration limits.
Runtime generation profiles are server data-pack resources. Their schema,
override rules, reload behavior, and deterministic fallback are documented in
[AGE_PROFILES.md](AGE_PROFILES.md).
## Administrator recovery
Use this sequence when a book cannot load an existing Age:
1. Stop the server and make a copy of the complete world folder.
2. Start the server and run `/rivencraft age list`. Confirm that the expected
canonical name and dimension identifier remain registered.
3. If the Age is listed as unloaded, open its Descriptive Book to reload it.
This keeps the UUID, seed, profile, traits, and existing world folder.
4. If a data-pack profile is missing or invalid, restore or repair that pack,
run `/reload`, then unload and reopen the Age. A removed custom profile uses
the deterministic `rivencraft:standard` fallback and logs the substitution.
5. If the definition is missing, restore the complete world backup. Moving a
dimension folder into another world cannot reconstruct its UUID or books.
6. If the definition exists but its dimension data is damaged, restore the
matching dimension data and authoritative world saved data from the same
backup generation. Do not combine them from different backups.
There is intentionally no delete command in Milestone 3. Do not manually
delete an Age definition or rename its dimension folder: books may exist in
inventories, containers, Book Stands, or unloaded chunks, and would be
orphaned. `/rivencraft age unload` is the supported reversible maintenance
operation.
+1 -1
View File
@@ -31,7 +31,7 @@ acceptance issue and all required implementation issues are complete.
**Complete.**
3. **Ages** — persistent generated dimensions, deterministic traits,
lifecycle management, data-pack integration, and optional destination
renderer compatibility. **Active.** See
renderer compatibility. **Complete.** See
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
4. **Writing system** — pages, symbols, notebooks, desks, and book assembly.
5. **Age grammar** — translate symbol combinations into modern biome, noise,
+1 -1
View File
@@ -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.29.0
mod_version=0.3.0.0
immersive_portals_version=6.0.7
# 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.
@@ -0,0 +1,132 @@
package net.dnorberg.rivencraft.book;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Saved-data migration and round-trip acceptance coverage for persistent Ages. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class AgePersistenceGameTests {
private AgePersistenceGameTests() {
}
@GameTest(template = "empty")
public static void everyTraitCombinationRoundTripsWithoutRerolling(GameTestHelper helper) {
ListTag entries = new ListTag();
AgeTraits.BiomeDistribution[] distributions = AgeTraits.BiomeDistribution.values();
AgeTraits.Lighting[] lightingValues = AgeTraits.Lighting.values();
AgeTraits.Weather[] weatherValues = AgeTraits.Weather.values();
for (int index = 0; index < distributions.length; index++) {
UUID id = new UUID(0x524956454E000000L + index, 0x4147450000000000L + index);
AgeTraits traits = new AgeTraits(
distributions[index],
lightingValues[index % lightingValues.length],
weatherValues[index % weatherValues.length],
index == 0 ? AgeTraits.STOPPED_CELESTIAL_PERIOD : 0.25D + index * 0.15D,
index * 37.5F,
index * 0.1F
);
entries.add(entry(
id,
"Persistent " + distributions[index],
9000L + index,
AgeGenerationProfiles.all().get(index % AgeGenerationProfiles.all().size()),
3 + index,
1200L + index,
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/persistent_" + index),
new BlockPos(index, 70 + index, -index),
traits
));
}
CompoundTag root = new CompoundTag();
root.put("Ages", entries);
AgeRegistrySavedData first = AgeRegistrySavedData.load(root, helper.getLevel().registryAccess());
CompoundTag serialized = first.save(new CompoundTag(), helper.getLevel().registryAccess());
AgeRegistrySavedData restored = AgeRegistrySavedData.load(serialized, helper.getLevel().registryAccess());
for (int index = 0; index < distributions.length; index++) {
UUID id = new UUID(0x524956454E000000L + index, 0x4147450000000000L + index);
helper.assertValueEqual(restored.find(id).orElseThrow(), first.find(id).orElseThrow(),
distributions[index] + " and its weather, lighting, celestial, and identity data must round-trip");
}
helper.succeed();
}
@GameTest(template = "empty")
public static void legacyDefinitionReceivesStableDefaults(GameTestHelper helper) {
UUID id = UUID.fromString("4d797374-6372-6166-7400-000000000001");
CompoundTag legacy = new CompoundTag();
legacy.putUUID("Id", id);
legacy.putString("Name", " Legacy Age ");
legacy.putLong("Seed", 8675309L);
legacy.putString("GenerationProfile", AgeDefinition.RANDOM_VANILLA_PROFILE.toString());
legacy.putLong("CreatedGameTime", 42L);
ListTag entries = new ListTag();
entries.add(legacy);
CompoundTag root = new CompoundTag();
root.put("Ages", entries);
AgeRegistrySavedData migrated = AgeRegistrySavedData.load(root, helper.getLevel().registryAccess());
AgeDefinition definition = migrated.find(id).orElseThrow();
helper.assertValueEqual(definition.id(), id, "Migration must retain the Age UUID");
helper.assertValueEqual(definition.name(), "Legacy Age", "Migration must normalize and retain the Age name");
helper.assertValueEqual(definition.seed(), 8675309L, "Migration must retain the Age seed");
helper.assertValueEqual(definition.generationProfile(), AgeDefinition.RANDOM_VANILLA_PROFILE,
"Migration must retain the legacy generation profile for deterministic runtime fallback");
helper.assertValueEqual(definition.generationVersion(), 1,
"Definitions predating generation versions must receive version 1");
helper.assertValueEqual(definition.dimensionId(), AgeDefinition.dimensionId(id),
"Definitions predating named dimensions must retain the UUID-derived dimension");
helper.assertValueEqual(definition.traits(), AgeTraits.LEGACY_DEFAULTS,
"Definitions predating traits must receive stable legacy defaults");
CompoundTag serialized = migrated.save(new CompoundTag(), helper.getLevel().registryAccess());
AgeDefinition reloaded = AgeRegistrySavedData.load(serialized, helper.getLevel().registryAccess())
.find(id).orElseThrow();
helper.assertValueEqual(reloaded, definition, "A migrated definition must not reroll on its next restart");
helper.succeed();
}
private static CompoundTag entry(
UUID id,
String name,
long seed,
ResourceLocation profile,
int version,
long createdTime,
ResourceLocation dimension,
BlockPos spawn,
AgeTraits traits
) {
CompoundTag entry = new CompoundTag();
entry.putUUID("Id", id);
entry.putString("Name", name);
entry.putLong("Seed", seed);
entry.putString("GenerationProfile", profile.toString());
entry.putInt("GenerationVersion", version);
entry.putLong("CreatedGameTime", createdTime);
entry.putString("Dimension", dimension.toString());
entry.putInt("SpawnX", spawn.getX());
entry.putInt("SpawnY", spawn.getY());
entry.putInt("SpawnZ", spawn.getZ());
CompoundTag serializedTraits = new CompoundTag();
serializedTraits.putString("BiomeDistribution", traits.biomeDistribution().name());
serializedTraits.putString("Lighting", traits.lighting().name());
serializedTraits.putString("Weather", traits.weather().name());
serializedTraits.putDouble("CelestialPeriod", traits.celestialPeriod());
serializedTraits.putFloat("CelestialAngle", traits.celestialAngle());
serializedTraits.putFloat("CelestialPhase", traits.celestialPhase());
entry.put("Traits", serializedTraits);
return entry;
}
}
@@ -103,7 +103,7 @@ public final class AgeRegistrySavedData extends SavedData {
return created;
}
private static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
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++) {
@@ -111,12 +111,18 @@ public final class AgeRegistrySavedData extends SavedData {
if (!entry.hasUUID(ID_TAG)) {
continue;
}
ResourceLocation profile = ResourceLocation.tryParse(entry.getString(PROFILE_TAG));
ResourceLocation profile = entry.contains(PROFILE_TAG, CompoundTag.TAG_STRING)
&& !entry.getString(PROFILE_TAG).isBlank()
? ResourceLocation.tryParse(entry.getString(PROFILE_TAG))
: null;
if (profile == null) {
profile = AgeDefinition.RANDOM_VANILLA_PROFILE;
}
UUID id = entry.getUUID(ID_TAG);
ResourceLocation dimensionId = ResourceLocation.tryParse(entry.getString(DIMENSION_TAG));
ResourceLocation dimensionId = entry.contains(DIMENSION_TAG, CompoundTag.TAG_STRING)
&& !entry.getString(DIMENSION_TAG).isBlank()
? ResourceLocation.tryParse(entry.getString(DIMENSION_TAG))
: null;
if (dimensionId == null) {
dimensionId = AgeDefinition.dimensionId(id);
}
@@ -241,6 +241,78 @@ public final class LinkingGameTests {
});
}
@GameTest(template = "empty", timeoutTicks = 400)
public static void everyTerrainProfileProvidesSafeArrivalAndReturnTravel(GameTestHelper helper) {
ServerPlayer player = helper.makeMockServerPlayerInLevel();
BlockPos returnPosition = player.blockPosition();
helper.getLevel().setBlock(returnPosition.below(), Blocks.STONE.defaultBlockState(), 2);
helper.getLevel().setBlock(returnPosition, Blocks.AIR.defaultBlockState(), 2);
helper.getLevel().setBlock(returnPosition.above(), Blocks.AIR.defaultBlockState(), 2);
LinkTarget returnTarget = new LinkTarget(
GlobalPos.of(helper.getLevel().dimension(), returnPosition),
player.getYRot(),
player.getXRot()
);
long worldSeed = helper.getLevel().getServer().overworld().getSeed();
runTerrainProfileTravel(helper, player, returnTarget, AgeGenerationProfiles.all(), 0, worldSeed);
}
private static void runTerrainProfileTravel(
GameTestHelper helper,
ServerPlayer player,
LinkTarget returnTarget,
java.util.List<net.minecraft.resources.ResourceLocation> profiles,
int index,
long worldSeed
) {
if (index >= profiles.size()) {
helper.succeed();
return;
}
var profile = profiles.get(index);
AgeReference reference = referenceSelectingProfile(profile, worldSeed);
LinkTarget destination = AgeDimensionService.resolve(player, reference).orElse(null);
helper.assertTrue(destination != null, profile + " must create a destination");
if (destination == null) {
return;
}
helper.runAfterDelay(1, () -> {
AgeDefinition definition = AgeRegistrySavedData.get(helper.getLevel()).find(reference.id()).orElseThrow();
helper.assertValueEqual(definition.generationProfile(), profile,
"The acceptance fixture must select " + profile);
var ageLevel = player.getServer().getLevel(destination.location().dimension());
helper.assertTrue(ageLevel != null, profile + " must create a loaded runtime dimension");
BlockPos spawn = destination.location().pos();
helper.assertTrue(ageLevel != null && ageLevel.getBlockState(spawn.below()).is(Blocks.COBBLESTONE),
profile + " must create a solid arrival platform");
helper.assertTrue(ageLevel != null && ageLevel.getBlockState(spawn).isAir()
&& ageLevel.getBlockState(spawn.above()).isAir(),
profile + " must create clear arrival headroom");
helper.assertValueEqual(LinkingService.travelWithFooting(player, destination),
LinkingService.LinkResult.SUCCESS, profile + " must accept safe inbound travel");
helper.assertValueEqual(LinkingService.travel(player, returnTarget),
LinkingService.LinkResult.SUCCESS, profile + " must permit return travel");
helper.runAfterDelay(1, () -> runTerrainProfileTravel(
helper, player, returnTarget, profiles, index + 1, worldSeed));
});
}
private static AgeReference referenceSelectingProfile(
net.minecraft.resources.ResourceLocation profile,
long worldSeed
) {
UUID id = UUID.randomUUID();
for (int candidate = 0; candidate < 100_000; candidate++) {
String name = "Profile Acceptance " + profile.getPath() + " " + id + " " + candidate;
var dimension = AgeDefinition.namedDimensionId(name);
long seed = worldSeed + dimension.hashCode();
if (AgeRuntimeProfiles.select(seed).equals(profile)) {
return new AgeReference(id, name);
}
}
throw new IllegalStateException("Could not select profile " + profile);
}
@GameTest(template = "empty", timeoutTicks = 100)
public static void unloadedAgeReloadsWithTheSameDefinition(GameTestHelper helper) {
UUID id = UUID.randomUUID();
@@ -529,7 +601,9 @@ public final class LinkingGameTests {
}
helper.assertTrue(level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream().allMatch(holder -> holder.is(biome)),
"The material test Age must select " + biome.location());
// Sample away from the generated arrival platform around the Age spawn.
// Sample away from the generated arrival platform. Vanilla surface rules
// can place a shallow local patch such as gravel over the biome's base block.
boolean foundExpectedSurface = false;
int sampleX = 64;
int sampleZ = 64;
level.getChunk(sampleX >> 4, sampleZ >> 4);
@@ -538,10 +612,15 @@ public final class LinkingGameTests {
sampleX,
sampleZ
) - 1;
var surfaceState = level.getBlockState(new BlockPos(sampleX, surfaceY, sampleZ));
helper.assertTrue(surfaceState.is(expectedBlock),
for (int depth = 0; depth <= 8; depth++) {
if (level.getBlockState(new BlockPos(sampleX, surfaceY - depth, sampleZ)).is(expectedBlock)) {
foundExpectedSurface = true;
break;
}
}
helper.assertTrue(foundExpectedSurface,
biome.location() + " terrain must expose " + expectedBlock.getDescriptionId()
+ " at the surface, found " + surfaceState);
+ " across the sampled surface");
}
@GameTest(template = "empty")