Refine Cave terrain environment and spawning (#14)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-21 08:38:23 -04:00
1 parent 09d2b0800c
commit b9faa28dd5
10 files changed
+164 -7

No files matched your search

@@ -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());
}
}
@@ -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.
@@ -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. */
@@ -15,6 +15,7 @@ import net.minecraft.resources.ResourceLocation;
public record AgeInstability(int version, int score, List<Contribution> 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<ResourceLocation, String> COUNTED_STRUCTURES = Map.ofEntries(
@@ -88,7 +89,19 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
private static void addAuditedSymbolCosts(AgeGenerationPlan plan, RegistryAccess registries,
List<Contribution> contributions) {
Map<ResourceLocation, Integer> 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()
@@ -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,
@@ -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");
@@ -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;
@@ -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<WritingEntry>();
entries.add(WritingEntry.linkPanel());
for (String page : pages) entries.add(WritingEntry.symbol(new SymbolReference(
ResourceLocation.parse("rivencraft:" + page))));
return new OrderedWriting(entries);
}
}