Complete instability regression slice and ordered modifiers (#73)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-12 14:47:15 -04:00
1 parent 4f464dbe60
commit 44205f80e0
14 files changed
+367 -37

No files matched your search

+23 -5
View File
@@ -1,4 +1,4 @@
# Live block-instability profiling (0.5.8.11–0.5.8.15) # Live block-instability profiling (0.5.8.11–0.5.8.17)
Mystcraft Legacy measures the abundance of watched blocks in generated chunks, Mystcraft Legacy measures the abundance of watched blocks in generated chunks,
compares that abundance with a baseline, and adds only positive excess to the compares that abundance with a baseline, and adds only positive excess to the
@@ -26,8 +26,24 @@ Legacy weights are used for its watched blocks: coal/lapis 5, iron 60, emerald
50. Deepslate variants inherit their ore's weight. Modern/high-value extensions 50. Deepslate variants inherit their ore's weight. Modern/high-value extensions
include diamond, emerald, gold, iron, raw gold, raw iron and netherite storage include diamond, emerald, gold, iron, raw gold, raw iron and netherite storage
blocks, plus ancient debris. Netherite blocks cost 8,000 per excess block per blocks, plus ancient debris. Netherite blocks cost 8,000 per excess block per
sampled chunk; ancient debris costs 4,000. Modded blocks are not automatically sampled chunk; ancient debris costs 4,000.
classified as valuable; this avoids charging unknown decorative materials.
Other mods remain optional. When an installed mod registers common-tagged ores
or storage blocks, recognizable `*_ore` blocks, or source-fluid blocks,
Rivencraft automatically includes those blocks in live profiling. The source
fluid weight is 1.25; common mineral names such as tin,
lead, zinc, silver, uranium, platinum, and iridium have explicit tiers, while
other tagged ores use 60 and other tagged storage blocks use 120. The optional
`instability_modded_materials.block_weight_overrides` list accepts a full-block
`modid:block_id=points` entry to cover a block without common tags or replace
its inferred weight; zero excludes it. Block entities and non-source fluid
plants are excluded. Rivencraft never requires any particular content mod.
Profiles written before 0.5.8.17 did not retain all modded material
counts, so their block sample data resets and is collected again after upgrade.
The saved Age definition and authored score are not changed. Later config
changes can rescore materials already captured in the profile; a newly added
override for a block that was not previously recognized needs fresh samples.
The authored score and provider plan remain saved as the Age's immutable base. The authored score and provider plan remain saved as the Age's immutable base.
Only Ages created with instability schema v2 participate; existing v1 Ages Only Ages created with instability schema v2 participate; existing v1 Ages
@@ -72,5 +88,7 @@ Do not retry that stalled scenario automatically. In a disposable world:
receive little or no ore contribution. Check that profiling does not make receive little or no ore contribution. Check that profiling does not make
chunk loading noticeably stall, and report any exception or server hang. chunk loading noticeably stall, and report any exception or server hang.
Do not use a valued save for this check. The game jar builds, but live chunk Do not use a valued save for this check. The player reported an in-game pass
profiling still needs this player-run validation before issue #73 is closed. for the 0.5.8.18 candidate on 2026-10-12. The three detailed scenarios above
remain useful for future regression runs; the report does not establish that
each was exercised individually.
+63
View File
@@ -27,6 +27,8 @@ handed to the player in [the block-profile checklist](LIVE_BLOCK_INSTABILITY.md)
| Decay loaded-chunk target and spawn protection | `AgeDecayGameTests.targetMustRemainInChosenLoadedChunk`, `savedSpawnAndPlatformAreProtected` | | Decay loaded-chunk target and spawn protection | `AgeDecayGameTests.targetMustRemainInChosenLoadedChunk`, `savedSpawnAndPlatformAreProtected` |
| Explosion policy gates and protected blocks | `Milestone6RegressionGameTests.disabledGriefAndProtectedBlocksStayUntouched` | | Explosion policy gates and protected blocks | `Milestone6RegressionGameTests.disabledGriefAndProtectedBlocksStayUntouched` |
| No Overworld status or decay provider | `AgeStatusEffectGameTests.overworldCannotRunAnAgeStatusProvider`; `AgeDecayGameTests.overworldCannotRunDecayProvider` | | No Overworld status or decay provider | `AgeStatusEffectGameTests.overworldCannotRunAnAgeStatusProvider`; `AgeDecayGameTests.overworldCannotRunDecayProvider` |
| Optional-mod material weights, block overrides, and old profile migration | `Milestone6RegressionGameTests.commonModdedMaterialWeightsStayBounded`, `blockProfileSamplesPersistWithoutDoubleCounting` |
| Ordered celestial modifier consumption, repeated modifiers, and dangling +100 per kind | `WritingGrammarGameTests.orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences`; `EnvironmentWritingGameTests.celestialModifiersFollowWrittenOrder` |
These tests exercise saved-data serialization and deterministic policies. They These tests exercise saved-data serialization and deterministic policies. They
do **not** simulate a complete process restart, an actual multiplayer server, do **not** simulate a complete process restart, an actual multiplayer server,
@@ -45,6 +47,9 @@ checks live only in the `instability` archive to avoid duplicating default tests
That check is narrower than arbitrary missing biome, block, or worldgen That check is narrower than arbitrary missing biome, block, or worldgen
registry entries: loading a world with removed data packs is not guaranteed. registry entries: loading a world with removed data packs is not guaranteed.
Broad save compatibility and migration remain tracked in #60. Broad save compatibility and migration remain tracked in #60.
- Version-3 plans keep their previous stage-sorted celestial interpretation and
frozen writing cost. Only new version-4 plans consume period/phase modifiers
in authored order and charge a trailing modifier once per pending kind.
- In-process roundtrips cannot prove that Age dimensions reload correctly - In-process roundtrips cannot prove that Age dimensions reload correctly
after a full stop/start. Check that in a real client/server world. Do not add after a full stop/start. Check that in a real client/server world. Do not add
another GameTest for it if it hits the documented storage stall; hand the another GameTest for it if it hits the documented storage stall; hand the
@@ -81,6 +86,13 @@ entry from a valued world; copy the test save first.
a disposable copy; confirm its Age season behavior and that Rivencraft a disposable copy; confirm its Age season behavior and that Rivencraft
itself still loads. JEI should display the relevant pages and recipes when itself still loads. JEI should display the relevant pages and recipes when
installed, while the mod works without it. installed, while the mod works without it.
Separately, install a mod with common-tagged ores/storage blocks or source
fluids. Compare a material-rich Age with an ordinary Age after the profile
thresholds are reached; the installed mod's recognized blocks should affect
the result without a Rivencraft allowlist. Then remove that optional mod in
a disposable copy and confirm Rivencraft still starts. Test a single
`block_weight_overrides` entry and a zero-weight exclusion. This remains a
player-run scenario, not an automated pass.
5. Check the book preview separately with its fallback animation, Ponder/Create, 5. Check the book preview separately with its fallback animation, Ponder/Create,
and Immersive Portals when available. Opening a book should not change and Immersive Portals when available. Opening a book should not change
player location, instability diagnostics, or ambient audio in the actual player location, instability diagnostics, or ambient audio in the actual
@@ -93,3 +105,54 @@ entry from a valued world; copy the test save first.
The matrix is guidance for a player-run pass; none of these six scenarios is The matrix is guidance for a player-run pass; none of these six scenarios is
claimed as completed by the automated result above. claimed as completed by the automated result above.
The player reported an in-game pass for the 0.5.8.18 candidate on 2026-10-12.
That is a release-slice acceptance report, not a claim that every expanded
matrix row below was individually exercised.
### Regression run record
For each row, record pass/fail, world seed, Age name, Rivencraft version,
optional-mod versions, and a short observation. "Not run" is intentional
until a player completes the in-game check; a successful build is not a pass.
| Area | Specific manual observation | Status |
| --- | --- | --- |
| New vs reused book | Dimension ID, score, and providers do not reroll | Not run |
| Full process restart | Re-enter two Ages; saved traits, score, plan, and cooldowns persist | Not run |
| Multiplayer isolation | Two Age players receive effects; Overworld player does not | Not run |
| Loaded-chunk scope | Departing players permit unloading; hazards do not reach old chunks | Not run |
| Controls | Global, per-Age, mutation, and provider toggles suppress effects without reroll | Not run |
| Protected blocks | Explosion/decay/erosion spare arrival platforms, portals, and block entities | Not run |
| Hazard visuals | Lightning, explosions, decay, status, burning, meteor, and erosion each behave when selected | Not run |
| Third-party biomes | TerraBlender biome pages and cave classification survive Age creation/restart | Not run |
| Optional integrations absent | Mod launches without JEI, Create/Ponder, Immersive Portals, Serene Seasons, or biome mods | Not run |
| Optional integrations present | JEI pages/recipes, preview backends, seasonal behavior, and ambient sound work | Not run |
| Optional-mod materials | Tagged ore/storage/source-fluid samples change live profile automatically | Not run |
| Material overrides | Positive and zero `block_weight_overrides` values affect only named blocks | Not run |
| Removed optional mod | Disposable copy launches without the mod; unknown sampled IDs stay serialized | Not run |
| Removed symbol/handler | Frozen score and provider plan survive when nonessential data is removed | Not run |
| Ordered modifiers | Five book-order cases below produce expected time and dangling costs | Not run |
### Focused ordered-writing and optional-material checks for 0.5.8.18
Use `/rivencraft age instability` in each newly bound Age; old Ages retain
their original plan and score. Record each row as pass/fail with the Age name,
world seed, installed optional mods, and the command output. The ordered
modifier assertions are archived, not a claim that the stalled GameTest suite ran.
| Book pages after Link Panel, in exact order | Expected result | Result |
| --- | --- | --- |
| Half Length, Double Length, Normal Time | Length combines to 1.25×, no dangling cost | Not run in game |
| Normal Time, Zero Length | Sun keeps normal motion; one dangling +100 | Not run in game |
| Rising, Normal Time, Setting | Rising start phase; Setting dangles +100 | Not run in game |
| Half Length, Double Length, no celestial page | One combined pending length dangles +100; seeded clock remains | Not run in game |
| Normal Time, Zero Length, Normal Time | Last sun consumes Zero Length; duplicate celestial cost remains separate | Not run in game |
With a common-tagged ore/storage-block or source-fluid mod installed, bind
comparable material-rich and ordinary Ages and wait for profiling thresholds.
Verify the modded material contributes automatically, then restart and confirm
the score/profile remains consistent. On a disposable copy, remove the mod and
confirm Rivencraft launches without it; restore the mod before judging the
world's registry-dependent terrain. Also check a zero-weight override and a
positive override. These are optional-mod compatibility checks, not a new
required dependency.
+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. # 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 mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.5.8.15 mod_version=0.5.8.18
immersive_portals_version=6.0.7 immersive_portals_version=6.0.7
terrablender_version=4.1.0.8 terrablender_version=4.1.0.8
serene_seasons_version_id=B6nTjh4b serene_seasons_version_id=B6nTjh4b
@@ -3,8 +3,10 @@ package net.dnorberg.rivencraft.book;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig; import net.dnorberg.rivencraft.config.RivencraftConfig;
@@ -16,6 +18,7 @@ import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus; import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.neoforged.neoforge.common.Tags;
import net.neoforged.neoforge.event.level.ChunkEvent; import net.neoforged.neoforge.event.level.ChunkEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent;
@@ -24,7 +27,19 @@ import net.neoforged.neoforge.event.tick.ServerTickEvent;
public final class AgeBlockInstability { public final class AgeBlockInstability {
// Legacy's watched blocks and factor-1 values; modern ore variants share their parent weight. // Legacy's watched blocks and factor-1 values; modern ore variants share their parent weight.
// Storage blocks are deliberate extensions, including the requested diamond-block case. // Storage blocks are deliberate extensions, including the requested diamond-block case.
private static volatile Map<ResourceLocation, Double> weights; private static final Map<ResourceLocation, Double> VANILLA_WEIGHTS = weights();
private static final Map<String, Double> COMMON_MINERAL_WEIGHTS = Map.ofEntries(
Map.entry("coal", 5.0), Map.entry("sulfur", 5.0), Map.entry("sulphur", 5.0),
Map.entry("salt", 5.0), Map.entry("tin", 60.0), Map.entry("lead", 60.0),
Map.entry("aluminum", 60.0), Map.entry("aluminium", 60.0),
Map.entry("zinc", 60.0), Map.entry("copper", 60.0),
Map.entry("iron", 60.0), Map.entry("nickel", 200.0),
Map.entry("osmium", 200.0), Map.entry("silver", 200.0),
Map.entry("emerald", 200.0), Map.entry("ruby", 200.0),
Map.entry("sapphire", 200.0), Map.entry("redstone", 250.0),
Map.entry("gold", 750.0), Map.entry("uranium", 750.0),
Map.entry("platinum", 750.0), Map.entry("diamond", 4000.0),
Map.entry("iridium", 4000.0), Map.entry("netherite", 8000.0));
private static final int MAX_PENDING = 2048; private static final int MAX_PENDING = 2048;
private static final int SAMPLES_PER_TICK = 4; private static final int SAMPLES_PER_TICK = 4;
private static final int GENERATION_INTERVAL_TICKS = 20; private static final int GENERATION_INTERVAL_TICKS = 20;
@@ -38,15 +53,73 @@ public final class AgeBlockInstability {
private AgeBlockInstability() {} private AgeBlockInstability() {}
static double weight(ResourceLocation id) { return watchedWeights().getOrDefault(id, 0.0); } /** Track recognized materials from installed mods without requiring any mod-specific API. */
static boolean tracked(ResourceLocation id) {
private static Map<ResourceLocation, Double> watchedWeights() { if (VANILLA_WEIGHTS.containsKey(id)) return true;
Map<ResourceLocation, Double> current = weights; if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
if (current != null) return current; if (!BuiltInRegistries.BLOCK.containsKey(id)) return false;
synchronized (AgeBlockInstability.class) { var block = BuiltInRegistries.BLOCK.get(id);
if (weights == null) weights = weights(); return eligibleMaterial(block) && (configuredOverride(id) != null || inferredModdedWeight(id, block) > 0);
return weights;
} }
static double weight(ResourceLocation id) {
Double vanilla = VANILLA_WEIGHTS.get(id);
if (vanilla != null) return vanilla;
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")
|| !BuiltInRegistries.BLOCK.containsKey(id)) return 0;
var block = BuiltInRegistries.BLOCK.get(id);
if (!eligibleMaterial(block)) return 0;
return moddedWeight(id, inferredModdedWeight(id, block),
RivencraftConfig.moddedMaterialOverrides());
}
private static boolean eligibleMaterial(net.minecraft.world.level.block.Block block) {
return BlockSymbolPages.isRockMaterial(block) || BlockSymbolPages.isFluidMaterial(block);
}
private static Double configuredOverride(ResourceLocation id) {
return configuredOverride(id, RivencraftConfig.moddedMaterialOverrides());
}
private static Double configuredOverride(ResourceLocation id, List<? extends String> overrides) {
for (String entry : overrides) {
int separator = entry.lastIndexOf('=');
if (separator > 0 && entry.substring(0, separator).equals(id.toString()))
return (double) Integer.parseInt(entry.substring(separator + 1));
}
return null;
}
static double moddedWeight(ResourceLocation id, double inferred, List<? extends String> overrides) {
Double override = configuredOverride(id, overrides);
return override == null ? inferred : override;
}
private static double inferredModdedWeight(ResourceLocation id, net.minecraft.world.level.block.Block block) {
if (BlockSymbolPages.isFluidMaterial(block)) return 1.25;
var state = block.defaultBlockState();
String path = id.getPath();
boolean ore = state.is(Tags.Blocks.ORES) || path.endsWith("_ore") || path.contains("_ore_");
boolean storage = state.is(Tags.Blocks.STORAGE_BLOCKS)
|| (path.endsWith("_block") || path.startsWith("block_of_"))
&& namedMineralWeight(path) != null;
return inferredModdedWeight(id, ore, storage, false);
}
static double inferredModdedWeight(ResourceLocation id, boolean ore, boolean storage, boolean fluid) {
if (fluid) return 1.25;
Double named = namedMineralWeight(id.getPath());
if (ore) return named == null ? 60.0 : named;
if (storage) return named == null ? 120.0 : named;
return 0;
}
private static Double namedMineralWeight(String path) {
for (String part : path.split("[/_]")) {
Double value = COMMON_MINERAL_WEIGHTS.get(part);
if (value != null) return value;
}
return null;
} }
public static void onChunkLoad(ChunkEvent.Load event) { public static void onChunkLoad(ChunkEvent.Load event) {
@@ -174,10 +247,14 @@ public final class AgeBlockInstability {
if (ageChunks < AgeBlockProfileSavedData.MIN_AGE_SAMPLES if (ageChunks < AgeBlockProfileSavedData.MIN_AGE_SAMPLES
|| baselineChunks < AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0; || baselineChunks < AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0;
double result = 0; double result = 0;
for (var entry : watchedWeights().entrySet()) { Set<ResourceLocation> watched = new HashSet<>(age.keySet());
double excess = age.getOrDefault(entry.getKey(), 0L) / (double) ageChunks watched.addAll(baseline.keySet());
- 1.05 * baseline.getOrDefault(entry.getKey(), 0L) / (double) baselineChunks; for (ResourceLocation id : watched) {
if (excess > 0) result += excess * entry.getValue(); double weight = weight(id);
if (weight <= 0) continue;
double excess = age.getOrDefault(id, 0L) / (double) ageChunks
- 1.05 * baseline.getOrDefault(id, 0L) / (double) baselineChunks;
if (excess > 0) result += excess * weight;
} }
return (int) Math.min(100_000, Math.round(result)); return (int) Math.min(100_000, Math.round(result));
} }
@@ -237,13 +314,6 @@ public final class AgeBlockInstability {
add(values, 4000, "ancient_debris"); add(values, 4000, "ancient_debris");
add(values, 60, "iron_block", "raw_iron_block"); add(values, 60, "iron_block", "raw_iron_block");
add(values, 8000, "netherite_block"); add(values, 8000, "netherite_block");
// Legacy automatically watched eligible non-vanilla fluid blocks with
// accessibility=1 and flat=0.25. Our abundance-only sampler uses their
// combined default factor until a spatial accessibility map is available.
BuiltInRegistries.BLOCK.keySet().stream()
.filter(id -> !id.getNamespace().equals("minecraft"))
.filter(id -> BlockSymbolPages.isFluidMaterial(BuiltInRegistries.BLOCK.get(id)))
.forEach(id -> values.putIfAbsent(id, 1.25));
return Map.copyOf(values); return Map.copyOf(values);
} }
@@ -15,6 +15,7 @@ import net.minecraft.world.level.saveddata.SavedData;
/** Persistent, bounded samples of generated block abundance for one dimension. */ /** Persistent, bounded samples of generated block abundance for one dimension. */
public final class AgeBlockProfileSavedData extends SavedData { public final class AgeBlockProfileSavedData extends SavedData {
private static final int PROFILE_VERSION = 2;
static final int MIN_AGE_SAMPLES = 401; static final int MIN_AGE_SAMPLES = 401;
static final int MIN_BASELINE_SAMPLES = 500; static final int MIN_BASELINE_SAMPLES = 500;
static final int MAX_SAMPLES = 1024; static final int MAX_SAMPLES = 1024;
@@ -43,7 +44,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
if (section.hasOnlyAir()) continue; if (section.hasOnlyAir()) continue;
section.getStates().count((state, amount) -> { section.getStates().count((state, amount) -> {
ResourceLocation id = BuiltInRegistries.BLOCK.getKey(state.getBlock()); ResourceLocation id = BuiltInRegistries.BLOCK.getKey(state.getBlock());
if (AgeBlockInstability.weight(id) > 0) counts.merge(id, amount, Integer::sum); if (AgeBlockInstability.tracked(id)) counts.merge(id, amount, Integer::sum);
}); });
} }
add(chunk.getPos(), counts); add(chunk.getPos(), counts);
@@ -59,6 +60,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
@Override @Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
tag.putInt("Version", PROFILE_VERSION);
ListTag entries = new ListTag(); ListTag entries = new ListTag();
for (var sample : samples.entrySet()) { for (var sample : samples.entrySet()) {
CompoundTag entry = new CompoundTag(); CompoundTag entry = new CompoundTag();
@@ -74,6 +76,9 @@ public final class AgeBlockProfileSavedData extends SavedData {
static AgeBlockProfileSavedData load(CompoundTag tag, HolderLookup.Provider registries) { static AgeBlockProfileSavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeBlockProfileSavedData data = new AgeBlockProfileSavedData(); AgeBlockProfileSavedData data = new AgeBlockProfileSavedData();
// Earlier profiles omitted some modded candidates. Resample rather than
// mixing incompatible chunk averages after installing content mods.
if (tag.getInt("Version") != PROFILE_VERSION) return data;
ListTag entries = tag.getList("Samples", CompoundTag.TAG_COMPOUND); ListTag entries = tag.getList("Samples", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < Math.min(entries.size(), MAX_SAMPLES); index++) { for (int index = 0; index < Math.min(entries.size(), MAX_SAMPLES); index++) {
CompoundTag entry = entries.getCompound(index); CompoundTag entry = entries.getCompound(index);
@@ -81,7 +86,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
Map<ResourceLocation, Integer> counts = new HashMap<>(); Map<ResourceLocation, Integer> counts = new HashMap<>();
for (String key : blocks.getAllKeys()) { for (String key : blocks.getAllKeys()) {
ResourceLocation id = ResourceLocation.tryParse(key); ResourceLocation id = ResourceLocation.tryParse(key);
if (id != null && AgeBlockInstability.weight(id) > 0 && blocks.getInt(key) > 0) if (id != null && blocks.getInt(key) > 0)
counts.put(id, blocks.getInt(key)); counts.put(id, blocks.getInt(key));
} }
data.add(new ChunkPos(entry.getLong("Chunk")), counts); data.add(new ChunkPos(entry.getLong("Chunk")), counts);
@@ -10,7 +10,17 @@ public final class AgeEnvironmentCompiler {
public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) { public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) {
if (plan == null) return seeded; if (plan == null) return seeded;
if (plan.version() >= 4) {
AgeTraits base = applyLegacy(plan, seeded);
CelestialWriting ordered = orderedCelestial(plan, seeded);
return new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(),
ordered.period(), base.celestialAngle(), ordered.phase());
}
return applyLegacy(plan, seeded);
}
/** Version-3 plans keep their frozen, stage-sorted environment interpretation. */
private static AgeTraits applyLegacy(AgeGenerationPlan plan, AgeTraits seeded) {
AgeTraits.Lighting lighting = seeded.lighting(); AgeTraits.Lighting lighting = seeded.lighting();
AgeTraits.Weather weather = seeded.weather(); AgeTraits.Weather weather = seeded.weather();
double period = seeded.celestialPeriod(); double period = seeded.celestialPeriod();
@@ -73,10 +83,45 @@ public final class AgeEnvironmentCompiler {
} }
public static boolean hasWrittenPhase(AgeGenerationPlan plan) { public static boolean hasWrittenPhase(AgeGenerationPlan plan) {
return plan != null && plan.activeSymbols().stream() if (plan == null) return false;
if (plan.version() >= 4) return orderedCelestial(plan, null).writtenPhase();
return plan.activeSymbols().stream()
.anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.PHASE_MODIFIER); .anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.PHASE_MODIFIER);
} }
private static CelestialWriting orderedCelestial(AgeGenerationPlan plan, AgeTraits seeded) {
double period = seeded == null ? 1.0D : seeded.celestialPeriod();
float phase = seeded == null ? 0.0F : seeded.celestialPhase();
Double pendingPeriod = null;
Float pendingPhase = null;
boolean writtenPhase = false;
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
SymbolSemantics semantics = symbol.semantics();
switch (semantics.role()) {
case PERIOD_MODIFIER -> {
double next = ((SymbolSemantics.NumberValue) semantics.value()).amount();
pendingPeriod = pendingPeriod == null ? next : (pendingPeriod + next) / 2.0D;
}
case PHASE_MODIFIER -> {
float next = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
pendingPhase = pendingPhase == null ? next : circularMidpoint(pendingPhase, next);
}
case CELESTIAL -> {
period = pendingPeriod == null ? 1.0D : pendingPeriod;
phase = pendingPhase == null ? (seeded == null ? 0.0F : seeded.celestialPhase())
: pendingPhase / 360.0F;
writtenPhase = pendingPhase != null;
pendingPeriod = null;
pendingPhase = null;
}
default -> { }
}
}
return new CelestialWriting(period, phase, writtenPhase);
}
private record CelestialWriting(double period, float phase, boolean writtenPhase) { }
public static int previewSkyColor(ResourceLocation effects, int biomeSkyColor) { public static int previewSkyColor(ResourceLocation effects, int biomeSkyColor) {
if (effects.equals(BuiltinDimensionTypes.NETHER_EFFECTS)) return 0xFF6B2725; if (effects.equals(BuiltinDimensionTypes.NETHER_EFFECTS)) return 0xFF6B2725;
if (effects.equals(BuiltinDimensionTypes.END_EFFECTS)) return 0xFF211835; if (effects.equals(BuiltinDimensionTypes.END_EFFECTS)) return 0xFF211835;
@@ -18,7 +18,7 @@ import net.minecraft.resources.ResourceLocation;
public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List<ResolvedSymbol> symbols, public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List<ResolvedSymbol> symbols,
AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection, AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection,
List<Integer> activeSymbolIndices, int writingInstability) { List<Integer> activeSymbolIndices, int writingInstability) {
public static final int CURRENT_VERSION = 3; public static final int CURRENT_VERSION = 4;
public AgeGenerationPlan { public AgeGenerationPlan {
if (version < 1 || version > CURRENT_VERSION) { if (version < 1 || version > CURRENT_VERSION) {
@@ -56,6 +56,12 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
contributions.add(new Contribution("random_baseline", -1, 0, "Page-free or Link-Panel-only Age")); contributions.add(new Contribution("random_baseline", -1, 0, "Page-free or Link-Panel-only Age"));
} else { } else {
contributions.add(new Contribution("authored_baseline", -1, 0, "Authored Age")); contributions.add(new Contribution("authored_baseline", -1, 0, "Authored Age"));
if (plan.version() < 4) {
// An old plan's writing cost was frozen under the previous grammar.
// Never validate it against the new ordered-modifier interpretation.
contributions.add(new Contribution("legacy_writing", -1, plan.writingInstability(),
"Writing cost frozen by the original Age plan"));
} else {
WritingGrammar.Result parsed = WritingGrammar.parse(plan.writing()); WritingGrammar.Result parsed = WritingGrammar.parse(plan.writing());
if (!parsed.canCreate() || parsed.writingInstability() != plan.writingInstability()) { if (!parsed.canCreate() || parsed.writingInstability() != plan.writingInstability()) {
throw new IllegalArgumentException("Writing changed between Age compilation and scoring"); throw new IllegalArgumentException("Writing changed between Age compilation and scoring");
@@ -64,6 +70,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
contributions.add(new Contribution(diagnostic.code().name().toLowerCase(java.util.Locale.ROOT), contributions.add(new Contribution(diagnostic.code().name().toLowerCase(java.util.Locale.ROOT),
diagnostic.index(), diagnostic.instability(), diagnostic.message())); diagnostic.index(), diagnostic.instability(), diagnostic.message()));
} }
}
addAuditedSymbolCosts(plan, registries, contributions); addAuditedSymbolCosts(plan, registries, contributions);
} }
// A missing lighting controller is seed-completed. Legacy runs the generated // A missing lighting controller is seed-completed. Legacy runs the generated
@@ -193,6 +193,48 @@ public final class EnvironmentWritingGameTests {
}); });
} }
@GameTest(template = "empty")
public static void celestialModifiersFollowWrittenOrder(GameTestHelper helper) {
AgeTraits seeded = AgeTraits.select(42L);
OrderedWriting consumed = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("period/half"), page("period/double"), page("phase/rising"),
page("celestial/normal_time")));
AgeGenerationPlan consumedPlan = AgeGenerationPlan.compile(consumed, 42L,
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
AgeTraits consumedTraits = AgeEnvironmentCompiler.apply(consumedPlan, seeded);
helper.assertValueEqual(consumedTraits.celestialPeriod(), 1.25D,
"Repeated period pages must combine before Normal Time consumes them");
helper.assertValueEqual(consumedTraits.celestialPhase(), 0.25F,
"A preceding phase page must be consumed by Normal Time");
helper.assertValueEqual(consumedPlan.writingInstability(), 0,
"Consumed modifiers must not be charged as dangling");
OrderedWriting dangling = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("celestial/normal_time"), page("period/zero"), page("phase/rising")));
AgeGenerationPlan danglingPlan = AgeGenerationPlan.compile(dangling, 42L,
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
AgeTraits danglingTraits = AgeEnvironmentCompiler.apply(danglingPlan, seeded);
helper.assertValueEqual(danglingTraits.celestialPeriod(), 1.0D,
"A period after Normal Time must not change the already-written sun");
helper.assertValueEqual(danglingTraits.celestialPhase(), seeded.celestialPhase(),
"A phase after Normal Time must not change the already-written sun");
helper.assertFalse(AgeEnvironmentCompiler.hasWrittenPhase(danglingPlan),
"An unconsumed phase page must not set the Age's start time");
helper.assertValueEqual(danglingPlan.writingInstability(), 200,
"Each unconsumed modifier kind must contribute Legacy's +100");
helper.assertValueEqual(AgeEnvironmentCompiler.apply(AgeGenerationPlan.load(danglingPlan.save()), seeded),
danglingTraits, "Ordered modifier behavior must survive reload");
AgeGenerationPlan previousVersion = new AgeGenerationPlan(3, danglingPlan.ageSeed(),
danglingPlan.writing(), danglingPlan.symbols(), danglingPlan.runtimeProfile(),
danglingPlan.biomeSelection(), danglingPlan.activeSymbolIndices(), danglingPlan.writingInstability());
helper.assertTrue(AgeEnvironmentCompiler.apply(previousVersion, seeded).hasStoppedCelestialCycle(),
"An existing version-3 Age must retain its earlier stage-sorted time behavior");
helper.assertTrue(AgeInstability.compute(previousVersion, seeded, helper.getLevel().registryAccess())
.breakdown().stream().anyMatch(entry -> entry.reason().equals("legacy_writing")),
"Version-3 writing cost must not be revalidated under version-4 grammar");
helper.succeed();
}
private static WritingEntry page(String path) { private static WritingEntry page(String path) {
return WritingEntry.symbol(new SymbolReference(ResourceLocation.fromNamespaceAndPath("rivencraft", path))); return WritingEntry.symbol(new SymbolReference(ResourceLocation.fromNamespaceAndPath("rivencraft", path)));
} }
@@ -109,6 +109,33 @@ public final class Milestone6RegressionGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void commonModdedMaterialWeightsStayBounded(GameTestHelper helper) {
var tin = net.minecraft.resources.ResourceLocation.parse("examplemod:deepslate_tin_ore");
var uranium = net.minecraft.resources.ResourceLocation.parse("examplemod:raw_uranium_block");
var oil = net.minecraft.resources.ResourceLocation.parse("examplemod:oil");
var unknown = net.minecraft.resources.ResourceLocation.parse("examplemod:unknown_ore");
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(tin, true, false, false), 60.0,
"Common tin ore should use the low mineral tier");
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(uranium, false, true, false), 750.0,
"A common radioactive mineral block should use its material tier");
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(oil, false, false, true), 1.25,
"Modded source fluids should use the Legacy fluid factor");
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(unknown, true, false, false), 60.0,
"Unknown but tagged ores should use a conservative fallback");
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(unknown, false, false, false), 0.0,
"Uncategorized modded blocks must not be watched automatically");
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0, List.of()), 60.0,
"Recognized ore from an installed mod should be scored automatically");
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0,
List.of("examplemod:deepslate_tin_ore=750")), 750.0,
"A block override should replace the discovered weight");
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0,
List.of("examplemod:deepslate_tin_ore=0")), 0.0,
"A zero-weight override should exclude a discovered block");
helper.succeed();
}
@GameTest(template = "empty") @GameTest(template = "empty")
public static void blockProfileSamplesPersistWithoutDoubleCounting(GameTestHelper helper) { public static void blockProfileSamplesPersistWithoutDoubleCounting(GameTestHelper helper) {
var data = new AgeBlockProfileSavedData(); var data = new AgeBlockProfileSavedData();
@@ -121,6 +148,11 @@ public final class Milestone6RegressionGameTests {
helper.getLevel().registryAccess()), helper.getLevel().registryAccess()); helper.getLevel().registryAccess()), helper.getLevel().registryAccess());
helper.assertValueEqual(restored.count(), 1, "The profiled chunk set must survive restart"); helper.assertValueEqual(restored.count(), 1, "The profiled chunk set must survive restart");
helper.assertValueEqual(restored.totals(), data.totals(), "Block counts must survive restart"); helper.assertValueEqual(restored.totals(), data.totals(), "Block counts must survive restart");
var oldProfile = data.save(new net.minecraft.nbt.CompoundTag(), helper.getLevel().registryAccess());
oldProfile.remove("Version");
helper.assertValueEqual(AgeBlockProfileSavedData.load(oldProfile,
helper.getLevel().registryAccess()).count(), 0,
"Profiles lacking modded candidate counts must resample after migration");
helper.succeed(); helper.succeed();
} }
} }
@@ -155,6 +155,8 @@ public final class RivencraftCommands {
source.sendSuccess(() -> Component.literal("Block profile: Age " + ageSamples source.sendSuccess(() -> Component.literal("Block profile: Age " + ageSamples
+ "/401, Overworld baseline " + baselineSamples + "/500" + "/401, Overworld baseline " + baselineSamples + "/500"
+ (ageLevel == null ? " (Age unloaded)" : "")), false); + (ageLevel == null ? " (Age unloaded)" : "")), false);
source.sendSuccess(() -> Component.literal("Modded materials: automatic when installed, "
+ RivencraftConfig.moddedMaterialOverrides().size() + " exact block override(s)"), false);
} }
boolean enabled = AgeInstabilityControls.get(source.getLevel()).enabled(age.id()); boolean enabled = AgeInstabilityControls.get(source.getLevel()).enabled(age.id());
source.sendSuccess(() -> Component.literal("Runtime: " + (enabled ? "Age enabled" : "Age disabled") source.sendSuccess(() -> Component.literal("Runtime: " + (enabled ? "Age enabled" : "Age disabled")
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.config; package net.dnorberg.rivencraft.config;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.common.ModConfigSpec; import net.neoforged.neoforge.common.ModConfigSpec;
@@ -32,6 +33,7 @@ public final class RivencraftConfig {
private static final ModConfigSpec.IntValue INSTABILITY_MAX_CHUNKS; private static final ModConfigSpec.IntValue INSTABILITY_MAX_CHUNKS;
private static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS; private static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS;
private static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS; private static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS;
private static final ModConfigSpec.ConfigValue<List<? extends String>> MODDED_MATERIAL_OVERRIDES;
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_ENABLED; private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_ENABLED;
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_PLAYERS; private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_PLAYERS;
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_NONPLAYERS; private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_NONPLAYERS;
@@ -111,6 +113,13 @@ public final class RivencraftConfig {
ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables the Accelerated page effect. The previous 1024-attempt default capped heavily accelerated Ages too early.") ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables the Accelerated page effect. The previous 1024-attempt default capped heavily accelerated Ages too early.")
.defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536); .defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536);
builder.pop(); builder.pop();
builder.push("instability_modded_materials");
MODDED_MATERIAL_OVERRIDES = builder.comment(
"Installed mods' recognized ores, mineral blocks and source fluids are scored automatically without requiring those mods.",
"Optional exact block weights use modid:block_id=points; zero excludes a block. Example: [\"examplemod:rare_ore=4000\"].")
.defineList("block_weight_overrides", List.<String>of(), () -> "examplemod:rare_ore=4000",
RivencraftConfig::validModdedMaterialOverride);
builder.pop();
builder.push("instability_status_effects"); builder.push("instability_status_effects");
STATUS_EFFECTS_ENABLED = builder.comment("Allow bounded instability status effects in loaded Ages.") STATUS_EFFECTS_ENABLED = builder.comment("Allow bounded instability status effects in loaded Ages.")
.define("enabled", true); .define("enabled", true);
@@ -236,6 +245,22 @@ public final class RivencraftConfig {
public static int instabilityMaxChunks() { return INSTABILITY_MAX_CHUNKS.get(); } public static int instabilityMaxChunks() { return INSTABILITY_MAX_CHUNKS.get(); }
public static int acceleratedMaxChunks() { return ACCELERATED_MAX_CHUNKS.get(); } public static int acceleratedMaxChunks() { return ACCELERATED_MAX_CHUNKS.get(); }
public static int acceleratedMaxAttempts() { return ACCELERATED_MAX_ATTEMPTS.get(); } public static int acceleratedMaxAttempts() { return ACCELERATED_MAX_ATTEMPTS.get(); }
public static List<? extends String> moddedMaterialOverrides() { return MODDED_MATERIAL_OVERRIDES.get(); }
private static boolean validModdedMaterialOverride(Object value) {
if (!(value instanceof String entry)) return false;
int separator = entry.lastIndexOf('=');
if (separator < 1) return false;
ResourceLocation id = ResourceLocation.tryParse(entry.substring(0, separator));
if (id == null || !entry.substring(0, separator).contains(":")
|| id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
try {
int weight = Integer.parseInt(entry.substring(separator + 1));
return weight >= 0 && weight <= 100_000;
} catch (NumberFormatException ignored) {
return false;
}
}
public static boolean statusEffectsEnabled() { return STATUS_EFFECTS_ENABLED.get(); } public static boolean statusEffectsEnabled() { return STATUS_EFFECTS_ENABLED.get(); }
public static boolean statusEffectsPlayers() { return STATUS_EFFECTS_PLAYERS.get(); } public static boolean statusEffectsPlayers() { return STATUS_EFFECTS_PLAYERS.get(); }
public static boolean statusEffectsNonplayers() { return STATUS_EFFECTS_NONPLAYERS.get(); } public static boolean statusEffectsNonplayers() { return STATUS_EFFECTS_NONPLAYERS.get(); }
@@ -87,6 +87,12 @@ public final class WritingGrammarGameTests {
WritingGrammar.Result repeatedModifiers = parse(resolver, WritingEntry.linkPanel(), WritingGrammar.Result repeatedModifiers = parse(resolver, WritingEntry.linkPanel(),
WritingEntry.symbol(periodHalf), WritingEntry.symbol(periodFull), WritingEntry.symbol(celestial)); WritingEntry.symbol(periodHalf), WritingEntry.symbol(periodFull), WritingEntry.symbol(celestial));
helper.assertTrue(repeatedModifiers.canCreate(), "Repeated period modifiers must remain legal"); helper.assertTrue(repeatedModifiers.canCreate(), "Repeated period modifiers must remain legal");
helper.assertValueEqual(repeatedModifiers.writingInstability(), 0,
"Repeated same-kind modifiers combine before the celestial page consumes them");
WritingGrammar.Result unconsumed = parse(resolver, WritingEntry.linkPanel(),
WritingEntry.symbol(periodHalf), WritingEntry.symbol(periodFull));
helper.assertValueEqual(unconsumed.writingInstability(), 100,
"Only the final combined modifier dangles when no celestial page follows");
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain), assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
WritingEntry.symbol(terrain)), WritingGrammar.Code.DUPLICATE); WritingEntry.symbol(terrain)), WritingGrammar.Code.DUPLICATE);
WritingGrammar.Result replacedTerrain = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain), WritingGrammar.Result replacedTerrain = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
@@ -96,8 +102,11 @@ public final class WritingGrammarGameTests {
"The last supported controller must supply the effective terrain"); "The last supported controller must supply the effective terrain");
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(weather), assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(weather),
WritingEntry.symbol(terrain)), WritingGrammar.Code.OUT_OF_ORDER); WritingEntry.symbol(terrain)), WritingGrammar.Code.OUT_OF_ORDER);
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial), WritingGrammar.Result afterCelestial = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial),
WritingEntry.symbol(periodHalf)), WritingGrammar.Code.MISPLACED_MODIFIER); WritingEntry.symbol(periodHalf));
assertPenalty(helper, afterCelestial, WritingGrammar.Code.DANGLING_MODIFIER);
helper.assertValueEqual(afterCelestial.writingInstability(), 100,
"A modifier after the last celestial page must be charged only once");
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(nativeBiomes), assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(nativeBiomes),
WritingEntry.symbol(plains)), WritingGrammar.Code.OUT_OF_ORDER); WritingEntry.symbol(plains)), WritingGrammar.Code.OUT_OF_ORDER);
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains), assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains),
@@ -40,6 +40,7 @@ public final class WritingGrammar {
if (writing.entries().isEmpty()) return new Result(symbols, diagnostics); if (writing.entries().isEmpty()) return new Result(symbols, diagnostics);
EnumMap<Role, ParsedSymbol> singular = new EnumMap<>(Role.class); EnumMap<Role, ParsedSymbol> singular = new EnumMap<>(Role.class);
EnumMap<Role, ParsedSymbol> pendingModifiers = new EnumMap<>(Role.class);
int panels = 0; int panels = 0;
int lastStage = -1; int lastStage = -1;
int biomeCount = 0; int biomeCount = 0;
@@ -72,6 +73,13 @@ public final class WritingGrammar {
Role role = semantics.orElseThrow().role(); Role role = semantics.orElseThrow().role();
ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow()); ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow());
symbols.add(parsed); symbols.add(parsed);
if (modifier(role)) {
// Legacy modifier pages pop and combine an earlier modifier of the same
// kind. Only the final pending value can be left dangling.
pendingModifiers.put(role, parsed);
} else if (role == Role.CELESTIAL) {
pendingModifiers.clear();
}
if (pendingBlock != null && role != Role.FEATURE) { if (pendingBlock != null && role != Role.FEATURE) {
// A material page without a following feature receives a seeded compatible // A material page without a following feature receives a seeded compatible
// feature when the Age plan is compiled. // feature when the Age plan is compiled.
@@ -107,8 +115,8 @@ public final class WritingGrammar {
} }
if (role == Role.BIOME) biomeCount++; if (role == Role.BIOME) biomeCount++;
int stage = stage(role); int stage = stage(role);
if (stage < lastStage) { if (stage < lastStage && !modifier(role)) {
diagnostics.add(new Diagnostic(modifier(role) ? Code.MISPLACED_MODIFIER : Code.OUT_OF_ORDER, diagnostics.add(new Diagnostic(Code.OUT_OF_ORDER,
index, "Move this " + role.id() + " page before later grammar categories")); index, "Move this " + role.id() + " page before later grammar categories"));
} else { } else {
lastStage = stage; lastStage = stage;
@@ -116,6 +124,10 @@ public final class WritingGrammar {
} }
if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1, if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1,
"Add a Link Panel as the first page")); "Add a Link Panel as the first page"));
for (ParsedSymbol dangling : pendingModifiers.values()) {
diagnostics.add(new Diagnostic(Code.DANGLING_MODIFIER, dangling.index(),
"This " + dangling.semantics().role().id() + " page has no later celestial page to consume it"));
}
if (symbols.isEmpty()) return new Result(symbols, diagnostics); if (symbols.isEmpty()) return new Result(symbols, diagnostics);
ParsedSymbol controller = singular.get(Role.BIOME_CONTROLLER); ParsedSymbol controller = singular.get(Role.BIOME_CONTROLLER);
@@ -174,14 +186,14 @@ public final class WritingGrammar {
public enum Code { public enum Code {
TOO_MANY, MISSING_PANEL, PANEL_ORDER, DUPLICATE_PANEL, UNRESOLVED, UNSUPPORTED, TOO_MANY, MISSING_PANEL, PANEL_ORDER, DUPLICATE_PANEL, UNRESOLVED, UNSUPPORTED,
DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, INCOMPLETE, UNSUPPORTED_PROFILE, DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, DANGLING_MODIFIER, INCOMPLETE, UNSUPPORTED_PROFILE,
UNKNOWN_BIOME, UNSUPPORTED_BIOME, UNSUPPORTED_ENVIRONMENT, UNSUPPORTED_FEATURE, UNKNOWN_BIOME, UNSUPPORTED_BIOME, UNSUPPORTED_ENVIRONMENT, UNSUPPORTED_FEATURE,
UNSUPPORTED_COMBINATION; UNSUPPORTED_COMBINATION;
public Severity severity() { public Severity severity() {
return switch (this) { return switch (this) {
case INCOMPLETE -> Severity.SEEDED_COMPLETION; case INCOMPLETE -> Severity.SEEDED_COMPLETION;
case DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER -> Severity.INSTABILITY; case DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, DANGLING_MODIFIER -> Severity.INSTABILITY;
default -> Severity.FATAL; default -> Severity.FATAL;
}; };
} }
@@ -205,7 +217,7 @@ public final class WritingGrammar {
public int instability() { public int instability() {
return switch (code) { return switch (code) {
case DUPLICATE, CONFLICT -> 500; case DUPLICATE, CONFLICT -> 500;
case OUT_OF_ORDER, MISPLACED_MODIFIER -> 100; case OUT_OF_ORDER, MISPLACED_MODIFIER, DANGLING_MODIFIER -> 100;
default -> 0; default -> 0;
}; };
} }