Compare commits

...
1 Commits
Author SHA1 Message Date
dnorberg b9faa28dd5 Refine Cave terrain environment and spawning (#14)
Build / build (push) Canceled after 0s
2026-10-21 08:38:23 -04:00
10 changed files with 164 additions and 7 deletions

No files matched your search

+21
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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);
}
}