diff --git a/gradle.properties b/gradle.properties index d916311..eea9edf 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.29.0 +mod_version=0.2.30.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. diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java new file mode 100644 index 0000000..9a6eb45 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java @@ -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; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java index 1d32812..c55a765 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java @@ -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); } diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index 78e9587..1de8894 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -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 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")