diff --git a/docs/AGE_PROFILES.md b/docs/AGE_PROFILES.md index 8ebb7ab..3f0243a 100644 --- a/docs/AGE_PROFILES.md +++ b/docs/AGE_PROFILES.md @@ -40,6 +40,27 @@ profiles, not claims that Legacy had identically named terrain symbols. Every family retains a safe cobblestone arrival platform. An omitted terrain page still uses the deterministic seed-selected profile. +### Cave terrain and Legacy + +Legacy's closest terrain generator is its Nether terrain symbol: it owns an +Age-seeded octave-noise generator, accepts terrain/sea materials, and sets a +sea level of 32. Legacy's separate Caves symbol instead registers a terrain +alteration that carves additional caves; it is not a terrain generator. +Minecraft 1.21.1 no longer has Legacy's 1.12.2 height and generation pipeline, +so Rivencraft Cave terrain uses `minecraft:caves` noise and the modern Cave, +Nether, and tagged modded Nether biome decorations. It cannot reproduce the +exact Legacy noise or its older vertical layout. The Caves feature page +remains a separate additive carver for other suitable terrain. + +Cave Ages search for safe spawn footing from Y=64 downward rather than using +the cave roof; if necessary, the usual cloud platform is placed at Y=63. +Weather is clear and Serene Seasons is disabled in Cave terrain. Authored +Weather, Season, and Celestial pages (including time modifiers) are retained +in the book but have no effect on the Cave Age; each adds 100 base instability. +The same policy applies to randomly selected Cave terrain and persists through +save/reload. Cave biome selections remain limited to Cave and Nether biomes; +surface-biome completion is not used for Cave terrain. + The separate **Floating Islands** feature page adds rare high-altitude island clusters above Standard, Amplified, Sky Islands, Flat, or Void terrain; it does not replace the terrain page. The feature is seeded by the Age and saved with the written diff --git a/gradle.properties b/gradle.properties index cc68152..30db6a5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,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.6.6.11 +mod_version=0.6.7.0 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=pHEgQQUE diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java index 611d763..2925751 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java @@ -118,4 +118,10 @@ public record AgeDefinition( createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode, randomFeatures, instability, instabilityPlan); } + + /** A frozen authored profile takes precedence over the profile ID on old books. */ + public boolean caveTerrain() { + return AgeBiomeSelection.isCave(plan == null + ? AgeRuntimeProfiles.resolve(generationProfile) : plan.runtimeProfile()); + } } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java index 3f3fd6e..589bd64 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java @@ -179,6 +179,10 @@ public final class AgeDimensionService { } private static void applyCelestialClock(ServerLevel level, AgeDefinition age) { + if (age.caveTerrain()) { + level.setDayTimePerTick(1.0F); + return; + } if (age.traits().hasStoppedCelestialCycle()) { // NeoForge rejects an exact zero rate. The smallest positive float // never advances the integer clock during a practical world @@ -207,6 +211,7 @@ public final class AgeDimensionService { } private static void applyInitialCelestialPhase(ServerLevel level, AgeDefinition age) { + if (age.caveTerrain()) return; long phaseTime = (long)(age.traits().celestialPhase() * Level.TICKS_PER_DAY); if (AgeEnvironmentCompiler.hasWrittenPhase(age.plan())) { // Legacy's Nadir is midnight, while vanilla day time zero is sunrise. @@ -227,7 +232,7 @@ public final class AgeDimensionService { private static BlockPos createSafeSpawn(ServerLevel level, AgeDefinition age, boolean generatePlatform) { BlockPos floorCenter; - if (age.generationProfile().equals(AgeGenerationProfiles.CAVE)) { + if (age.caveTerrain()) { // Cave Ages are enclosed. Searching from the world ceiling would // select or expose their roof, so begin with the floor beneath a // player standing at Y=64 and search downward from there. diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java b/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java index 6f93598..38ba1ed 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java @@ -10,13 +10,23 @@ public final class AgeEnvironmentCompiler { public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) { if (plan == null) return seeded; + AgeTraits applied; if (plan.version() >= 4) { AgeTraits base = applyLegacy(plan, seeded); CelestialWriting ordered = orderedCelestial(plan, seeded); - return new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(), + applied = new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(), ordered.period(), base.celestialAngle(), ordered.phase()); + } else { + applied = applyLegacy(plan, seeded); } - return applyLegacy(plan, seeded); + return forTerrain(applied, plan.runtimeProfile()); + } + + /** Cave terrain has no exposed weather or meaningful sky clock, even with authored pages. */ + public static AgeTraits forTerrain(AgeTraits traits, AgeRuntimeProfile profile) { + if (!AgeBiomeSelection.isCave(profile)) return traits; + return new AgeTraits(traits.biomeDistribution(), traits.lighting(), AgeTraits.Weather.OFF, + 1.0D, 0.0F, 0.0F); } /** Version-3 plans keep their frozen, stage-sorted environment interpretation. */ diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java b/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java index 9ea9631..217efe7 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java @@ -15,6 +15,7 @@ import net.minecraft.resources.ResourceLocation; public record AgeInstability(int version, int score, List breakdown) { public static final int CURRENT_VERSION = 2; private static final int BRIGHT_LIGHTING_COST = 500; + private static final int INEFFECTIVE_CAVE_PAGE_COST = 100; private static final int EXCESS_LEGACY_STRUCTURE_COST = 100; private static final int FORCED_STRUCTURE_BIOME_COST = 100; private static final Map COUNTED_STRUCTURES = Map.ofEntries( @@ -88,7 +89,19 @@ public record AgeInstability(int version, int score, List breakdow private static void addAuditedSymbolCosts(AgeGenerationPlan plan, RegistryAccess registries, List contributions) { Map structureCounts = new HashMap<>(); + boolean cave = AgeBiomeSelection.isCave(plan.runtimeProfile()); for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) { + if (cave) { + var role = symbol.semantics().role(); + if (switch (role) { + case WEATHER, SEASON, CELESTIAL, PERIOD_MODIFIER, PHASE_MODIFIER, ANGLE_MODIFIER -> true; + default -> false; + }) { + contributions.add(new Contribution("cave_ignored_" + role.id(), symbol.index(), + INEFFECTIVE_CAVE_PAGE_COST, + "Sky or weather page has no effect in Cave terrain")); + } + } if (symbol.semantics().role() == net.dnorberg.rivencraft.writing.SymbolSemantics.Role.ENVIRONMENT && symbol.semantics().value() diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java index 81d5ac5..ce22212 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java @@ -176,7 +176,9 @@ public final class AgeRegistrySavedData extends SavedData { ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries); AgeTraits traits = AgeTraits.select(seed); AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries); - AgeTraits effectiveTraits = AgeEnvironmentCompiler.apply(plan, traits); + AgeTraits effectiveTraits = plan == null + ? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile)) + : AgeEnvironmentCompiler.apply(plan, traits); AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries); AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed); AgeDefinition created = new AgeDefinition( @@ -190,7 +192,8 @@ public final class AgeRegistrySavedData extends SavedData { null, effectiveTraits, plan, - AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")), + AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"), + plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile()), plan == null ? AgeFeatureSelection.randomChoices(seed, AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(), instability, diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeSeasonMode.java b/src/main/java/net/dnorberg/rivencraft/book/AgeSeasonMode.java index f8a4748..5fee177 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeSeasonMode.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeSeasonMode.java @@ -19,6 +19,12 @@ public enum AgeSeasonMode { /** An omitted page is selected once when an Age is created with Serene installed. */ public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled) { + return select(plan, ageSeed, sereneInstalled, plan == null ? null : plan.runtimeProfile()); + } + + public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled, + AgeRuntimeProfile profile) { + if (profile != null && AgeBiomeSelection.isCave(profile)) return NO_SEASONS; if (hasAuthoredPage(plan)) return from(plan); return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of( new LegacyRankChoice.Option<>(SEASONS, 1), @@ -31,6 +37,7 @@ public enum AgeSeasonMode { /** Existing Ages without a stored choice retain their old cycling behavior. */ public static AgeSeasonMode from(AgeDefinition age) { + if (age.caveTerrain()) return NO_SEASONS; return age.seasonMode() == null ? from(age.plan()) : age.seasonMode(); } @@ -49,6 +56,7 @@ public enum AgeSeasonMode { public static AgeSeasonMode from(AgeGenerationPlan plan) { if (plan == null) return SEASONS; + if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return NO_SEASONS; return plan.activeSymbols().stream() .filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON) .map(symbol -> valueOf(((SymbolSemantics.ChoiceValue) symbol.semantics().value()) @@ -58,6 +66,7 @@ public enum AgeSeasonMode { public static void validate(AgeGenerationPlan plan) { if (plan == null) return; + if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return; if (hasAuthoredPage(plan) && from(plan) != NO_SEASONS && !ModList.get().isLoaded("sereneseasons")) { throw new IllegalArgumentException("This seasonal page requires Serene Seasons to create the Age"); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeWeatherService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeWeatherService.java index 83fdce3..b906d2b 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeWeatherService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeWeatherService.java @@ -26,7 +26,7 @@ public final class AgeWeatherService { return; } - AgeTraits.Weather weather = age.traits().weather(); + AgeTraits.Weather weather = age.caveTerrain() ? AgeTraits.Weather.OFF : age.traits().weather(); if (weather == AgeTraits.Weather.NORMAL) { STATES.remove(level); return; diff --git a/src/main/java/net/dnorberg/rivencraft/book/CaveTerrainGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/CaveTerrainGameTests.java new file mode 100644 index 0000000..4b0ebe3 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/CaveTerrainGameTests.java @@ -0,0 +1,90 @@ +package net.dnorberg.rivencraft.book; + +import java.util.List; +import java.util.UUID; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.writing.OrderedWriting; +import net.dnorberg.rivencraft.writing.SymbolReference; +import net.dnorberg.rivencraft.writing.WritingEntry; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.biome.Biomes; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +/** Focused Cave terrain writing and persisted-generation coverage for issue #14. */ +@GameTestHolder(Rivencraft.MOD_ID) +@PrefixGameTestTemplate(false) +public final class CaveTerrainGameTests { + private CaveTerrainGameTests() {} + + @GameTest(template = "empty") + public static void caveSkyAndWeatherPagesCostInstabilityButDoNotApply(GameTestHelper helper) { + OrderedWriting cave = writing("terrain/cave", "biome/lush_caves", "biome/nether_wastes", + "biome/tiled", "weather/storm", "season/eternal_winter", "period/zero", + "celestial/normal_time"); + AgeTraits seeded = new AgeTraits(AgeTraits.BiomeDistribution.SINGLE, AgeTraits.Lighting.NORMAL, + AgeTraits.Weather.ALWAYS, 0.5D, 0.0F, 0.25F); + var registries = helper.getLevel().registryAccess(); + AgeGenerationPlan plan = AgeGenerationPlan.compile(cave, 42L, AgeGenerationProfiles.CAVE, + seeded, registries); + AgeTraits applied = AgeEnvironmentCompiler.apply(plan, seeded); + helper.assertValueEqual(applied.weather(), AgeTraits.Weather.OFF, "Cave weather must be off"); + helper.assertValueEqual(applied.celestialPeriod(), 1.0D, "Cave time pages must not alter the clock"); + helper.assertValueEqual(applied.celestialPhase(), 0.0F, "Cave time pages must not set a phase"); + helper.assertValueEqual(AgeSeasonMode.select(plan, 42L, true), AgeSeasonMode.NO_SEASONS, + "Cave seasons must be disabled even when Serene Seasons is present"); + helper.assertValueEqual(plan.biomeSelection().biomes(), + List.of(Biomes.LUSH_CAVES.location(), Biomes.NETHER_WASTES.location()), + "Cave writing must retain exactly its cave and Nether biomes"); + AgeInstability instability = AgeInstability.compute(plan, applied, registries); + helper.assertValueEqual(instability.breakdown().stream() + .filter(part -> part.reason().startsWith("cave_ignored_")).count(), 4L, + "Each ineffective weather, season, period, and celestial page must add instability"); + helper.assertValueEqual(AgeInstability.compute(AgeGenerationPlan.load(plan.save()), applied, registries), + instability, "Cave page penalties must survive plan reload"); + + AgeGenerationPlan surfaceCaves = AgeGenerationPlan.compile(writing("terrain/standard", "feature/caves"), + 42L, AgeGenerationProfiles.STANDARD, seeded, registries); + helper.assertTrue(!AgeBiomeSelection.isCave(surfaceCaves.runtimeProfile()) + && AgeFeatureSelection.resolved(surfaceCaves).stream() + .anyMatch(choice -> AgeFeatureSelection.caves(choice.id())), + "The separate Caves feature page must still add caves to surface terrain"); + helper.succeed(); + } + + @GameTest(template = "empty", timeoutTicks = 600) + public static void authoredCaveBiomeAndSpawnSurviveSavedDataReload(GameTestHelper helper) { + var player = helper.makeMockServerPlayerInLevel(); + AgeReference reference = new AgeReference(UUID.randomUUID(), "Cave issue 14 " + UUID.randomUUID()); + AgeDimensionService.resolve(player, reference, + writing("terrain/cave", "biome/nether_wastes", "biome/single")).orElseThrow(); + var registry = AgeRegistrySavedData.get(helper.getLevel()); + AgeDefinition age = registry.find(reference.id()).orElseThrow(); + helper.assertTrue(age.caveTerrain(), "The Age must retain Cave terrain"); + helper.assertTrue(age.spawn() != null && age.spawn().getY() <= 64, + "Cave spawn search must start at Y=64, not atop the roof"); + helper.assertValueEqual(age.traits().weather(), AgeTraits.Weather.OFF, + "A new Cave Age must persist disabled weather"); + helper.assertValueEqual(age.seasonMode(), AgeSeasonMode.NO_SEASONS, + "A new Cave Age must persist disabled seasons"); + AgeDefinition reloaded = AgeRegistrySavedData.load( + registry.save(new CompoundTag(), helper.getLevel().registryAccess()), + helper.getLevel().registryAccess()).find(reference.id()).orElseThrow(); + helper.assertValueEqual(reloaded, age, "Saved Cave biome, spawn, and environment must survive reload"); + helper.assertValueEqual(reloaded.plan().biomeSelection().biomes(), + List.of(Biomes.NETHER_WASTES.location()), + "The generated Cave Age must preserve its authored-only Nether pool"); + helper.succeed(); + } + + private static OrderedWriting writing(String... pages) { + var entries = new java.util.ArrayList(); + entries.add(WritingEntry.linkPanel()); + for (String page : pages) entries.add(WritingEntry.symbol(new SymbolReference( + ResourceLocation.parse("rivencraft:" + page)))); + return new OrderedWriting(entries); + } +}