Compare commits

...
3 Commits
Author SHA1 Message Date
dnorberg 32b676db30 Weight random Age choices like Legacy and expand regression coverage (#41)
Build / build (push) Canceled after 0s
2026-10-08 05:43:18 -04:00
dnorberg a287e092f5 Use Nether materials for Nether cave biomes (#59)
Build / build (push) Canceled after 0s
2026-10-08 02:29:59 -04:00
dnorberg 4f1d458897 Map Star Fissures to modern world minimum Y (#58)
Build / build (push) Canceled after 0s
2026-10-08 02:20:33 -04:00
19 changed files with 358 additions and 50 deletions

No files matched your search

+21
View File
@@ -47,6 +47,27 @@ automated check, and hand its exact scenario to the player for in-game
verification. Do not keep retrying a stalled test or leave its process running.
Keep the narrower non-stalling plan, persistence, and runtime checks automated.
For issue #41, the combined archive and the focused `linking` archive initially
stalled during the multi-profile travel acceptance test. That test is no longer
registered with GameTest; the rerun passed all 116 registered tests before the
subsequent random-biome-count adjustment. The 51-test core suite passes after
that adjustment, but the full archive stalled again late in its first batch;
do not treat that interrupted run as a pass. Verify its
scenario manually: in a fresh world, bind
one Descriptive Book for each of Standard, Flat, Amplified, Island, Cave, and
Void terrain (use the corresponding Terrain Page to select each one). Visit
each Age, confirm a safe arrival platform and headroom, and
travel back using a bound Linking Book. The narrower creation, persistence,
terrain, and travel checks remain automated. See
[random Age weights](RANDOM_AGE_WEIGHTS.md) for the Legacy rank mapping and
the opt-in weighting regression.
Also bind several Link-Panel-only Descriptive Books with no authored biome
pages. Check that each Age retains its biome set after leaving, unloading, and
reopening it, and that the selected biome count varies between Ages without
exceeding the eligible pool. This covers the bounded random-biome path touched
after the last successful full-archive run.
The optional `-PwithSereneSeasons=true` runtime is for a real dev client, not
the GameTest server: Serene Seasons sends a season payload to GameTest mock
players, which cannot negotiate its channel. Follow the real-client Serene test in
+18
View File
@@ -0,0 +1,18 @@
# Random Age selection (issue #41)
Page-free and Link-Panel-only books still make one random Age definition from the Age seed. A written page overrides its branch; random completion never rewrites the book. The resulting terrain, traits, season mode, biome pool, and feature choices are saved with the Age identity, so loading an existing Age does not reroll it. This change affects **new** Ages only.
The rank-weight calculation follows Mystcraft Legacy's `GrammarGenerator.buildRankWeights` in the user-provided 0.13.7.06 classes: starting at the highest numbered (rarest) rank, a rank's weight is at least one greater than the preceding rank's total divided by the number of entries at the current rank. A missing intermediate rank still advances the weight. The corresponding `SymbolRules` entries supply the ranks. This is a selection policy, not a port of Legacy's full grammar tree or its Minecraft 1.12 world generator.
| Branch | Relative tickets for supported choices |
| --- | --- |
| Terrain | Standard 6, Flat 5, Amplified/Island/Cave/Void 1 each. Cave and Island occupy the rare slots corresponding to Legacy Nether and End terrain. Additional data-pack runtime profiles default to the rare rank and cause the group weights to be recomputed. |
| Biome controller | Native/Single/Medium 3 each; Tiled/Grid/Tiny/Small/Large/Huge 1 each. The selected controller still applies terrain eligibility and minimum-biome rules. |
| Additional random biomes | After Rivencraft's existing controller minimum, each extra biome has a 2-in-3 continuation chance and a 1-in-3 stop chance, capped by the eligible biome pool (including optional TerraBlender additions). This mirrors Legacy's ranked biome-list branch without changing the project's authored-page minimums. |
| Lighting | Normal 3; Dark/Bright 1 each. |
| Weather | Normal 9; Fast/Slow/Off/Always/Cloudy/Rain/Snow/Storm 1 each. WeatherOn is named Always here. |
| Season (Serene Seasons only) | Seasons 6, No Seasons 5, each eternal season 1. These are Rivencraft ranks for features absent from Legacy; normal cycling is most common and eternal seasons are rare. No season policy is rolled without Serene Seasons. |
Random-feature selection remains bounded to two material features and two structures. Within those limits, surface/deep lakes have Legacy rank 1; lava lakes rank 2 as a separate new fluid choice; spheres/spikes/obelisks rank 3; and floating islands/tendrils rank 4. Rocks, which are a Rivencraft generalization, use rank 4. For structures, villages/strongholds/mineshafts use rank 1, dungeons/Nether fortresses rank 2, and newer Minecraft structures rank 3. Eligibility filtering still removes features incompatible with the selected terrain/biomes. Separate rare Skylands, Crystals, and Legacy-weighted Star Fissure rolls remain. This preserves Rivencraft's existing count limits rather than claiming exact parity with the full Legacy feature grammar. Existing saved random feature lists are retained. The current celestial stopped-time chance (1 in 7) follows the verified Legacy period ranks; other numeric celestial values still use Rivencraft's documented range.
Regression coverage: `RandomAgeWeightGameTests` (archived grammar suite) checks exact terrain rank tickets and stable selections across many seeds. Core Age persistence tests cover page-free and Link-Panel-only books, plan/trait round-trips, and migrated definitions. Binder and grammar archives cover page order, missing symbols, rejected assembly, and replayed output. The default GameTest runtime has none of TerraBlender, Serene Seasons, Ecliptic Seasons, JEI, Create, Ponder, or Immersive Portals installed. Optional-mod interactions and real unload/restart checks remain manual per `GAMETESTS.md`.
+1 -1
View File
@@ -20,6 +20,6 @@ Material pages are generated for registered full-collision, non-fluid blocks and
Crystal Formations are an authored, material-free feature on exposed solid terrain, including Flat terrain; Cave and Void reject the page. Like Legacy, one in fifteen eligible chunks attempts a formation, with the site fixed by the Age seed and chunk coordinates. The modern formation is a compact mound with budding hearts instead of Legacy's long crystal lines. Budding blocks grow through small, medium, and large buds into clusters using vanilla amethyst timing and sounds. Non-silk cluster harvest yields Crystal Shards; four shards craft one Crystal Block. These blocks and shards use 16×16 textures: Legacy's crystal tile for solids and hue-shifted vanilla amethyst sprites for buds and clusters. A page-free Age can also randomly select Crystal Formations; an authored page always retains its exact choice in the saved plan.
Star Fissures are a material-free feature on every terrain profile, including Cave and Void. The fissure is generated once beside the first resolved spawn, using Legacy's spawn-chunk offset and 10–17 paired, irregular X-span rows running along +Z. Each portal tile is placed at y=0 and its column is opened to the sky, as in Legacy. For modern terrain with a surface below y=0, the portal uses the minimum build height plus one instead; empty Void columns keep y=0. Its non-solid portal links to the Overworld spawn with the Legacy fissure sound; the animated starfield is drawn with Minecraft's current End Portal renderer, which uses the same end-sky/end-portal art as Legacy's custom renderer. Random Ages sample Legacy's default small-feature grammar for fissure selection: the stop/one/repeat branches have rank-derived weights 1:1:3, and the fissure has weight 1 of 9 among its small-feature rules. The remainder of random feature selection still follows Rivencraft's current pipeline, rather than running Legacy's entire Age grammar.
Star Fissures are a material-free feature on every terrain profile, including Cave and Void. The fissure is generated once beside the first resolved spawn, using Legacy's spawn-chunk offset and 10–17 paired, irregular X-span rows running along +Z. Legacy's y=0 portal floor maps to the modern dimension's minimum build height, replacing lower bedrock if present. The rift opens to the sky on ordinary terrain; in Cave terrain it opens into the cavern but stops below the upper bedrock roof. Water in the carved columns is cleared before the portal is placed. Its non-solid portal links to the Overworld spawn with the Legacy fissure sound; the animated starfield is drawn with Minecraft's current End Portal renderer, which uses the same end-sky/end-portal art as Legacy's custom renderer. Random Ages sample Legacy's default small-feature grammar for fissure selection: the stop/one/repeat branches have rank-derived weights 1:1:3, and the fissure has weight 1 of 9 among its small-feature rules. The remainder of random feature selection still follows Rivencraft's current pipeline, rather than running Legacy's entire Age grammar.
The Rivencraft symbol-page creative tabs read a generated index of bundled symbol definitions and group Pages by each definition's category, except biome-controller roles, which appear in a separate Biome Generation tab rather than the Biome tab. Adding another valid JSON symbol under `src/main/resources/data/rivencraft/rivencraft/symbols/` includes its page in the matching category tab on the next build; a new bundled category receives its own tab. Registry-derived Block and Fluid Pages have separate tabs and both appear in optional JEI. Link Pages have a separate tab. Blank Pages appear in neither the creative tabs nor JEI's ingredient list. The server catalog still decides whether a page is valid for writing. External server data-pack symbols are not included in this bundled index.
+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.4.19.1
mod_version=0.4.21.0
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=B6nTjh4b
@@ -80,13 +80,13 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
int minimum = minimum(distribution);
int target = minimum;
if (authored.isEmpty()) {
// The terrain's available pool is the upper bound, including any
// compatible TerraBlender region biomes present at creation time.
// Legacy's BiomesAdv continuation/stop ranks are 2:3, which yield
// two continuation tickets for every one stop ticket. Keep our
// existing minimum and cap the list at the available biome pool.
int availableBiomeCount = new LinkedHashSet<>(availableBiomes).size();
int maximum = distribution == AgeTraits.BiomeDistribution.SINGLE ? 1 : availableBiomeCount;
if (maximum >= minimum) {
target += new Random(seed ^ 0x42494F4D45434F55L).nextInt(maximum - minimum + 1);
}
Random countRandom = new Random(seed ^ 0x42494F4D45434F55L);
while (target < maximum && countRandom.nextInt(3) != 0) target++;
}
// An authored Void Age must not acquire The Void as an unrequested second biome.
List<ResourceLocation> filler = profile.terrain() == AgeRuntimeProfile.Terrain.VOID && !authored.isEmpty()
@@ -15,6 +15,7 @@ import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
@@ -103,7 +104,8 @@ public final class AgeDimensionService {
.anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES))
|| definition.randomFeatures().stream()
.anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES))) {
StarFissureFeature.generate(level, definition.seed(), spawn);
StarFissureFeature.generate(level, definition.seed(), spawn,
AgeBiomeSelection.isCave(runtimeProfile(definition)));
}
definition = AgeRegistrySavedData.get(level).setSpawn(definition.id(), spawn);
}
@@ -336,9 +338,11 @@ public final class AgeDimensionService {
Registries.NOISE_SETTINGS, profile.noiseSettings());
NoiseGeneratorSettings baseSettings = noiseSettings.getOrThrow(settingsKey).value();
Holder<NoiseGeneratorSettings> settings = Holder.direct(withBiomeMaterialRules(baseSettings));
boolean caveTerrain = AgeBiomeSelection.isCave(profile);
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
? new NoiseBasedChunkGenerator(biomeSource, settings)
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed());
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed(),
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of());
}
private static NoiseGeneratorSettings withBiomeMaterialRules(NoiseGeneratorSettings base) {
@@ -159,13 +159,15 @@ public final class AgeFeatureSelection {
public static List<Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries) {
Random random = new Random(ageSeed ^ 0x4D4154455249414CL);
int count = random.nextInt(3);
List<ResourceLocation> features = new ArrayList<>();
for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null) features.add(id);
for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null) features.add(id);
Collections.shuffle(features, random);
List<LegacyRankChoice.Option<ResourceLocation>> features = new ArrayList<>();
for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null)
features.add(new LegacyRankChoice.Option<>(id, randomMaterialRank(id)));
for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null)
features.add(new LegacyRankChoice.Option<>(id, randomMaterialRank(id)));
List<Choice> choices = new ArrayList<>();
for (ResourceLocation feature : features) {
if (choices.size() >= count) break;
while (choices.size() < count && !features.isEmpty()) {
ResourceLocation feature = LegacyRankChoice.choose(random, features);
features.removeIf(option -> option.value().equals(feature));
var category = solidFeature(feature)
? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY;
List<ResourceLocation> materials = BlockSymbolPages.availableBlocks(category).stream()
@@ -186,6 +188,13 @@ public final class AgeFeatureSelection {
return List.copyOf(choices);
}
static int randomMaterialRank(ResourceLocation feature) {
if (feature.equals(id("surface_lakes")) || feature.equals(id("deep_lakes"))) return 1;
if (feature.equals(id("lava_lakes"))) return 2; // New separate fluid choice.
if (spheres(feature) || spikes(feature) || obelisks(feature)) return 3;
return 4; // Legacy Floating Islands/Tendrils and the new Rocks choice.
}
/**
* Legacy's default FeatureSmalls0 grammar: stop without a feature (rank 4),
* emit one (rank 4), or emit one and repeat (rank 2). Its rank-derived
@@ -1,6 +1,8 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.Random;
import java.util.Comparator;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.ResourceLocation;
@@ -13,24 +15,37 @@ public final class AgeGenerationProfiles {
public static final ResourceLocation FLAT = id("flat");
public static final ResourceLocation VOID = id("void");
private static final List<ResourceLocation> RANDOM_PROFILES = List.of(
STANDARD, AMPLIFIED, ISLAND, CAVE, FLAT, VOID
private static final List<LegacyRankChoice.Option<ResourceLocation>> RANDOM_PROFILES = List.of(
new LegacyRankChoice.Option<>(STANDARD, 1),
new LegacyRankChoice.Option<>(FLAT, 2),
new LegacyRankChoice.Option<>(AMPLIFIED, 3),
new LegacyRankChoice.Option<>(ISLAND, 3),
new LegacyRankChoice.Option<>(CAVE, 3),
new LegacyRankChoice.Option<>(VOID, 3)
);
private static final List<LegacyRankChoice.Option<ResourceLocation>> SORTED_RANDOM_PROFILES =
RANDOM_PROFILES.stream().sorted(Comparator.comparing(LegacyRankChoice.Option::value)).toList();
private AgeGenerationProfiles() {
}
/** Selects one stable base terrain from the Age's already-persisted seed. */
public static ResourceLocation select(long seed) {
return RANDOM_PROFILES.get(Math.floorMod(Long.hashCode(seed), RANDOM_PROFILES.size()));
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), SORTED_RANDOM_PROFILES);
}
public static boolean isSupported(ResourceLocation profile) {
return RANDOM_PROFILES.contains(profile);
return RANDOM_PROFILES.stream().anyMatch(option -> option.value().equals(profile));
}
public static List<ResourceLocation> all() {
return RANDOM_PROFILES;
return RANDOM_PROFILES.stream().map(LegacyRankChoice.Option::value).toList();
}
static int randomRank(ResourceLocation profile) {
if (profile.equals(STANDARD)) return 1;
if (profile.equals(FLAT)) return 2;
return 3;
}
/** Maps removed legacy aliases to a stable built-in profile. */
@@ -6,6 +6,7 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.Map;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.OrderedWriting;
@@ -59,8 +60,9 @@ public final class AgeRuntimeProfiles {
if (PROFILES.isEmpty()) {
return AgeGenerationProfiles.select(seed);
}
java.util.List<ResourceLocation> ids = PROFILES.keySet().stream().sorted().toList();
return ids.get(Math.floorMod(Long.hashCode(seed), ids.size()));
List<LegacyRankChoice.Option<ResourceLocation>> profiles = PROFILES.keySet().stream().sorted()
.map(id -> new LegacyRankChoice.Option<>(id, AgeGenerationProfiles.randomRank(id))).toList();
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), profiles);
}
/** Keep seed-selected terrain random, but only among profiles able to host authored features. */
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.book;
import java.util.Locale;
import java.util.Random;
import java.util.List;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.neoforged.fml.ModList;
@@ -19,7 +20,13 @@ 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) {
if (hasAuthoredPage(plan)) return from(plan);
return sereneInstalled ? values()[new Random(ageSeed ^ RANDOM_SALT).nextInt(values().length)] : null;
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
new LegacyRankChoice.Option<>(SEASONS, 1),
new LegacyRankChoice.Option<>(NO_SEASONS, 2),
new LegacyRankChoice.Option<>(ETERNAL_WINTER, 3),
new LegacyRankChoice.Option<>(ETERNAL_SUMMER, 3),
new LegacyRankChoice.Option<>(ETERNAL_FALL, 3),
new LegacyRankChoice.Option<>(ETERNAL_SPRING, 3))) : null;
}
/** Existing Ages without a stored choice retain their old cycling behavior. */
@@ -1,7 +1,6 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -116,13 +115,25 @@ public final class AgeStructureSelection {
public static List<AgeFeatureSelection.Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile,
AgeBiomeSelection biomes, RegistryAccess registries) {
List<ResourceLocation> eligible = new ArrayList<>(FEATURES.stream()
.filter(id -> error(id, profile, biomes, registries) == null).toList());
List<LegacyRankChoice.Option<ResourceLocation>> eligible = new ArrayList<>(FEATURES.stream()
.filter(id -> error(id, profile, biomes, registries) == null)
.map(id -> new LegacyRankChoice.Option<>(id, randomRank(id))).toList());
Random random = new Random(ageSeed ^ 0x5354525543545552L);
Collections.shuffle(eligible, random);
int count = random.nextInt(3);
return eligible.stream().limit(count)
.map(id -> new AgeFeatureSelection.Choice(id, Optional.empty())).toList();
List<AgeFeatureSelection.Choice> choices = new ArrayList<>();
while (choices.size() < count && !eligible.isEmpty()) {
ResourceLocation selected = LegacyRankChoice.choose(random, eligible);
choices.add(new AgeFeatureSelection.Choice(selected, Optional.empty()));
eligible.removeIf(option -> option.value().equals(selected));
}
return List.copyOf(choices);
}
static int randomRank(ResourceLocation feature) {
if (feature.equals(id("villages")) || feature.equals(id("strongholds"))
|| feature.equals(id("mineshafts"))) return 1;
if (feature.equals(id("dungeons")) || feature.equals(id("nether_fortresses"))) return 2;
return 3; // Structures added after Legacy are uncommon, but still discoverable.
}
private static Set<ResourceLocation> enabledSets(List<AgeFeatureSelection.Choice> choices) {
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.book;
import java.util.Random;
import java.util.List;
/** Immutable random-Age traits selected once from the Age seed. */
public record AgeTraits(
@@ -42,9 +43,30 @@ public record AgeTraits(
public static AgeTraits select(long seed) {
Random random = new Random(seed ^ 0x524956454E435241L);
BiomeDistribution biomeDistribution = randomChoice(random, BiomeDistribution.values());
Lighting lighting = randomChoice(random, Lighting.values());
Weather weather = randomChoice(random, Weather.values());
BiomeDistribution biomeDistribution = LegacyRankChoice.choose(random, List.of(
new LegacyRankChoice.Option<>(BiomeDistribution.NATIVE, 1),
new LegacyRankChoice.Option<>(BiomeDistribution.SINGLE, 1),
new LegacyRankChoice.Option<>(BiomeDistribution.MEDIUM, 1),
new LegacyRankChoice.Option<>(BiomeDistribution.TILED, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.GRID, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.TINY, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.SMALL, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.LARGE, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.HUGE, 2)));
Lighting lighting = LegacyRankChoice.choose(random, List.of(
new LegacyRankChoice.Option<>(Lighting.NORMAL, 1),
new LegacyRankChoice.Option<>(Lighting.DARK, 2),
new LegacyRankChoice.Option<>(Lighting.BRIGHT, 2)));
Weather weather = LegacyRankChoice.choose(random, List.of(
new LegacyRankChoice.Option<>(Weather.NORMAL, 1),
new LegacyRankChoice.Option<>(Weather.FAST, 2),
new LegacyRankChoice.Option<>(Weather.SLOW, 2),
new LegacyRankChoice.Option<>(Weather.OFF, 2),
new LegacyRankChoice.Option<>(Weather.ALWAYS, 2),
new LegacyRankChoice.Option<>(Weather.CLOUDY, 2),
new LegacyRankChoice.Option<>(Weather.RAIN, 2),
new LegacyRankChoice.Option<>(Weather.SNOW, 2),
new LegacyRankChoice.Option<>(Weather.STORM, 2)));
// Legacy's PERIOD_BASIC grammar gives ModZero rank 2 and its other
// three length modifiers rank 1, producing an effective 1-in-7 weight.
boolean stopped = random.nextInt(7) == 0;
@@ -77,10 +99,6 @@ public record AgeTraits(
}
}
private static <E> E randomChoice(Random random, E[] values) {
return values[random.nextInt(values.length)];
}
public enum BiomeDistribution {
NATIVE, SINGLE, TILED, GRID, TINY, SMALL, MEDIUM, LARGE, HUGE
}
@@ -14,6 +14,7 @@ import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
@@ -460,6 +461,32 @@ public final class FeatureWritingGameTests {
});
}
@GameTest(template = "empty", timeoutTicks = 600)
public static void netherCaveBiomeUsesNetherRock(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/cave"),
page("biome/nether_wastes"), page("biome/single")));
var target = AgeDimensionService.resolve(player,
new AgeReference(UUID.randomUUID(), "Nether Cave Materials"), writing).orElseThrow();
helper.runAfterDelay(1, () -> {
var level = helper.getLevel().getServer().getLevel(target.location().dimension());
helper.assertTrue(level != null, "The Nether cave Age must load");
var chunk = level.getChunk(0, 0);
int[] rock = new int[2];
chunk.findBlocks(state -> state.is(Blocks.NETHERRACK) || state.is(Blocks.STONE)
|| state.is(Blocks.DEEPSLATE) || state.is(Blocks.DIRT)
|| state.is(Blocks.GRASS_BLOCK),
(pos, state) -> {
if (state.is(Blocks.NETHERRACK)) rock[0]++;
else rock[1]++;
});
helper.assertTrue(rock[0] > 0, "Nether cave terrain must contain netherrack");
helper.assertValueEqual(rock[1], 0,
"Nether cave terrain must not retain stone, deepslate, dirt, or grass");
helper.succeed();
});
}
private static void rejects(GameTestHelper helper, OrderedWriting writing, String expected) {
try {
AgeGenerationPlan.compile(writing, 87L, AgeGenerationProfiles.STANDARD,
@@ -0,0 +1,44 @@
package net.dnorberg.rivencraft.book;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
/** Legacy grammar ranks: each more-preferred rank group outweighs the next rarer group. */
final class LegacyRankChoice {
private LegacyRankChoice() {}
record Option<T>(T value, int rank) {
Option {
if (value == null || rank < 0) throw new IllegalArgumentException("Invalid ranked choice");
}
}
static <T> T choose(Random random, List<Option<T>> options) {
if (options.isEmpty()) throw new IllegalArgumentException("No ranked choices");
Map<Integer, Integer> counts = new HashMap<>();
int highest = 0;
for (Option<T> option : options) {
counts.merge(option.rank(), 1, Integer::sum);
highest = Math.max(highest, option.rank());
}
Map<Integer, Integer> weights = new HashMap<>();
int weight = 1;
int previousTotal = 0;
for (int rank = highest; rank >= 0; rank--) {
int count = counts.getOrDefault(rank, 0);
if (weight != 1 && count > 0) weight = Math.max(weight, previousTotal / count + 1);
weights.put(rank, weight);
previousTotal = count * weight;
weight++;
}
int total = options.stream().mapToInt(option -> weights.get(option.rank())).sum();
int ticket = random.nextInt(total);
for (Option<T> option : options) {
ticket -= weights.get(option.rank());
if (ticket < 0) return option.value();
}
throw new IllegalStateException("Ranked choice ticket was not consumed");
}
}
@@ -0,0 +1,87 @@
package net.dnorberg.rivencraft.book;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Opt-in regression for Legacy rank weights and deterministic random-Age branches. */
@GameTestHolder("rivencraft_archive_grammar")
@PrefixGameTestTemplate(false)
public final class RandomAgeWeightGameTests {
private RandomAgeWeightGameTests() {}
@GameTest(template = "empty")
public static void randomAgeChoicesRespectLegacyRanks(GameTestHelper helper) {
var terrain = List.of(new LegacyRankChoice.Option<>("normal", 1),
new LegacyRankChoice.Option<>("flat", 2),
new LegacyRankChoice.Option<>("amplified", 3),
new LegacyRankChoice.Option<>("island", 3),
new LegacyRankChoice.Option<>("cave", 3),
new LegacyRankChoice.Option<>("void", 3));
// Legacy's rank formula yields 6:5:1:1:1:1 for these six terrains.
for (int ticket = 0; ticket < 15; ticket++) {
final int selectedTicket = ticket;
Random fixed = new Random() {
@Override public int nextInt(int bound) {
helper.assertValueEqual(bound, 15, "The terrain rank group must have 15 tickets");
return selectedTicket;
}
};
String expected = ticket < 6 ? "normal" : ticket < 11 ? "flat"
: terrain.get(ticket - 9).value();
helper.assertValueEqual(LegacyRankChoice.choose(fixed, terrain), expected,
"A fixed ticket must map to its exact Legacy terrain weight");
}
Map<ResourceLocation, Integer> profiles = new HashMap<>();
Map<AgeTraits.Weather, Integer> weather = new HashMap<>();
Map<AgeSeasonMode, Integer> seasons = new HashMap<>();
for (long seed = 0; seed < 4_000; seed++) {
ResourceLocation profile = AgeRuntimeProfiles.select(seed);
helper.assertValueEqual(profile, AgeRuntimeProfiles.select(seed),
"The same Age seed must choose the same terrain");
if (AgeRuntimeProfiles.profiles().size() == AgeGenerationProfiles.all().size())
helper.assertValueEqual(profile, AgeGenerationProfiles.select(seed),
"The built-in-only fallback must agree with loaded profile selection");
profiles.merge(profile, 1, Integer::sum);
AgeTraits traits = AgeTraits.select(seed);
helper.assertValueEqual(traits, AgeTraits.select(seed),
"The same Age seed must choose the same traits");
weather.merge(traits.weather(), 1, Integer::sum);
AgeSeasonMode season = AgeSeasonMode.select(null, seed, true);
helper.assertValueEqual(season, AgeSeasonMode.select(null, seed, true),
"The same Age seed must choose the same season policy");
seasons.merge(season, 1, Integer::sum);
}
helper.assertTrue(profiles.getOrDefault(AgeGenerationProfiles.STANDARD, 0)
> profiles.getOrDefault(AgeGenerationProfiles.FLAT, 0)
&& profiles.getOrDefault(AgeGenerationProfiles.FLAT, 0)
> profiles.getOrDefault(AgeGenerationProfiles.VOID, 0),
"Normal terrain must be common, Flat intermediate, and Void rare");
helper.assertTrue(weather.getOrDefault(AgeTraits.Weather.NORMAL, 0)
> weather.getOrDefault(AgeTraits.Weather.STORM, 0),
"Legacy's normal weather must be more common than a special weather mode");
helper.assertTrue(seasons.getOrDefault(AgeSeasonMode.SEASONS, 0)
> seasons.getOrDefault(AgeSeasonMode.NO_SEASONS, 0)
&& seasons.getOrDefault(AgeSeasonMode.NO_SEASONS, 0)
> seasons.getOrDefault(AgeSeasonMode.ETERNAL_WINTER, 0),
"Normal season cycling must be common, no seasons intermediate, and eternal seasons rare");
helper.assertTrue(AgeSeasonMode.select(null, 42L, false) == null,
"No season policy may be selected without Serene Seasons");
helper.assertValueEqual(AgeFeatureSelection.randomMaterialRank(ResourceLocation.parse("rivencraft:surface_lakes")),
1, "Surface Lakes must retain their Legacy common rank");
helper.assertValueEqual(AgeFeatureSelection.randomMaterialRank(ResourceLocation.parse("rivencraft:floating_islands")),
4, "Floating Islands must retain their Legacy rare rank");
helper.assertValueEqual(AgeStructureSelection.randomRank(ResourceLocation.parse("rivencraft:villages")),
1, "Villages must retain their Legacy common rank");
helper.assertValueEqual(AgeStructureSelection.randomRank(ResourceLocation.parse("rivencraft:trial_chambers")),
3, "Modern structures must use a rare rank");
helper.succeed();
}
}
@@ -242,7 +242,8 @@ public final class LinkingGameTests {
});
}
@GameTest(template = "empty", timeoutTicks = 400)
// Manual acceptance only: six sequential dynamic dimensions and round-trip
// travel can leave the dedicated GameTest server waiting indefinitely.
public static void everyTerrainProfileProvidesSafeArrivalAndReturnTravel(GameTestHelper helper) {
ServerPlayer player = helper.makeMockServerPlayerInLevel();
BlockPos returnPosition = player.blockPosition();
@@ -6,6 +6,7 @@ import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import net.dnorberg.rivencraft.book.AgeFeatureSelection;
import net.dnorberg.rivencraft.book.AgeStructureSelection;
@@ -16,6 +17,7 @@ import net.minecraft.core.Holder;
import net.minecraft.core.registries.Registries;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.QuartPos;
import net.minecraft.tags.BiomeTags;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.RandomSource;
@@ -55,23 +57,54 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
instance.group(BiomeSource.CODEC.fieldOf("biome_source").forGetter(generator -> generator.getBiomeSource()),
NoiseGeneratorSettings.CODEC.fieldOf("settings").forGetter(generator -> generator.settings),
AgeFeatureSelection.Choice.CODEC.listOf().fieldOf("age_features").forGetter(generator -> generator.features),
Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed))
Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed),
Codec.BOOL.optionalFieldOf("nether_cave_materials", false).forGetter(generator -> generator.netherCaveMaterials),
ResourceLocation.CODEC.listOf().optionalFieldOf("nether_biomes", List.of())
.forGetter(generator -> generator.netherBiomeIds))
.apply(instance, AgeFeatureChunkGenerator::new));
private final Holder<NoiseGeneratorSettings> settings;
private final List<AgeFeatureSelection.Choice> features;
private final long ageSeed;
private final boolean netherCaveMaterials;
private final List<ResourceLocation> netherBiomeIds;
public AgeFeatureChunkGenerator(BiomeSource biomes, Holder<NoiseGeneratorSettings> settings,
List<AgeFeatureSelection.Choice> features, long ageSeed) {
List<AgeFeatureSelection.Choice> features, long ageSeed,
boolean netherCaveMaterials, List<ResourceLocation> netherBiomeIds) {
super(biomes, settings);
this.settings = settings;
this.features = List.copyOf(features);
this.ageSeed = ageSeed;
this.netherCaveMaterials = netherCaveMaterials;
this.netherBiomeIds = List.copyOf(netherBiomeIds);
}
public List<AgeFeatureSelection.Choice> ageFeatures() { return features; }
@Override
public void buildSurface(WorldGenRegion level, StructureManager structures, RandomState random, ChunkAccess chunk) {
super.buildSurface(level, structures, random, chunk);
if (!netherCaveMaterials) return;
Set<ResourceLocation> netherIds = Set.copyOf(netherBiomeIds);
// The Cave noise router starts with stone even in Nether biomes. Surface rules
// only touch exposed layers, so convert the base rock (and any Overworld soil)
// before carvers, ores, and biome decorations run. Never change adjacent biomes.
chunk.findBlocks(AgeFeatureChunkGenerator::isOverworldCaveMaterial, (pos, state) -> {
Holder<net.minecraft.world.level.biome.Biome> biome = chunk.getNoiseBiome(
QuartPos.fromBlock(pos.getX()), QuartPos.fromBlock(pos.getY()), QuartPos.fromBlock(pos.getZ()));
if (biome.is(BiomeTags.IS_NETHER)
|| biome.unwrapKey().map(key -> netherIds.contains(key.location())).orElse(false)) {
chunk.setBlockState(pos, Blocks.NETHERRACK.defaultBlockState(), false);
}
});
}
private static boolean isOverworldCaveMaterial(net.minecraft.world.level.block.state.BlockState state) {
return state.is(Blocks.STONE) || state.is(Blocks.DEEPSLATE) || state.is(Blocks.TUFF)
|| state.is(Blocks.DIRT) || state.is(Blocks.GRASS_BLOCK);
}
@Override
protected MapCodec<? extends ChunkGenerator> codec() { return CODEC; }
@@ -155,6 +155,8 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
@Override public void applyCarvers(WorldGenRegion level, long seed, RandomState random, BiomeManager biomes,
StructureManager structures, ChunkAccess chunk, GenerationStep.Carving step) { }
@Override public void spawnOriginalMobs(WorldGenRegion level) { }
// Keep vanilla FlatLevelSource's generator API values. Actual layer
// placement uses chunk.getMinBuildHeight() above, not this metadata value.
@Override public int getMinY() { return 0; }
@Override public int getGenDepth() { return 384; }
@Override public int getSeaLevel() { return -63; }
@@ -49,29 +49,38 @@ public final class StarFissureFeature {
private static void addRow(List<BlockPos> columns, int originX, int originZ, int row, int left, int right) {
for (int x = left; x <= right; x++) {
// Footprint is a 2D column map; this placeholder Y is never used
// for placement. generate() resolves the real floor from the level.
columns.add(new BlockPos(originX + x, 0, originZ + row * 2));
columns.add(new BlockPos(originX + x, 0, originZ + row * 2 + 1));
}
}
public static void generate(ServerLevel level, long ageSeed, BlockPos spawn) {
public static void generate(ServerLevel level, long ageSeed, BlockPos spawn, boolean caveTerrain) {
List<BlockPos> columns = footprint(ageSeed, spawn);
BlockPos first = columns.getFirst();
// 1.12 worlds began at y=0. Modern superflat can sit below it, so use the
// modern world floor there. Empty Void columns keep Legacy's y=0 portal.
int surface = level.getHeight(Heightmap.Types.WORLD_SURFACE, first.getX(), first.getZ());
int minY = level.getMinBuildHeight();
int floorY = surface > minY && surface <= 0 ? minY + 1 : Math.max(0, minY + 1);
// Legacy's y=0 was its minimum build height. The equivalent in 1.21.1
// is the dimension's minimum Y, including the bedrock layer itself.
int floorY = level.getMinBuildHeight();
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
for (BlockPos column : columns) {
int top = level.getHeight(Heightmap.Types.WORLD_SURFACE, column.getX(), column.getZ());
level.setBlock(mutable.set(column.getX(), floorY, column.getZ()),
ModBlocks.STAR_FISSURE.get().defaultBlockState(), 2);
// Legacy clears all blocks above each portal tile to the sky. Heightmap
// bounds the same work without iterating already-empty world height.
if (caveTerrain) {
// Cave terrain has a bedrock roof. Open the fissure into the
// cavern, but never punch through its upper boundary.
for (int y = Math.max(spawn.getY(), floorY + 1); y < top; y++) {
if (level.getBlockState(mutable.set(column.getX(), y, column.getZ())).is(Blocks.BEDROCK)) {
top = y;
break;
}
}
}
// Clear the full column first, including water. Place the portal
// last so an existing fluid cannot occupy its block during carving.
for (int y = floorY + 1; y < top; y++) {
level.setBlock(mutable.set(column.getX(), y, column.getZ()), Blocks.AIR.defaultBlockState(), 2);
}
level.setBlock(mutable.set(column.getX(), floorY, column.getZ()),
ModBlocks.STAR_FISSURE.get().defaultBlockState(), 2);
}
}
}