diff --git a/docs/LIVE_BLOCK_INSTABILITY.md b/docs/LIVE_BLOCK_INSTABILITY.md index 25f388b..5de731f 100644 --- a/docs/LIVE_BLOCK_INSTABILITY.md +++ b/docs/LIVE_BLOCK_INSTABILITY.md @@ -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, 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 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 -sampled chunk; ancient debris costs 4,000. Modded blocks are not automatically -classified as valuable; this avoids charging unknown decorative materials. +sampled chunk; ancient debris costs 4,000. + +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. 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 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 -profiling still needs this player-run validation before issue #73 is closed. +Do not use a valued save for this check. The player reported an in-game pass +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. diff --git a/docs/MILESTONE_6_REGRESSION.md b/docs/MILESTONE_6_REGRESSION.md index 32fce77..a782bfc 100644 --- a/docs/MILESTONE_6_REGRESSION.md +++ b/docs/MILESTONE_6_REGRESSION.md @@ -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` | | Explosion policy gates and protected blocks | `Milestone6RegressionGameTests.disabledGriefAndProtectedBlocksStayUntouched` | | 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 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 registry entries: loading a world with removed data packs is not guaranteed. 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 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 @@ -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 itself still loads. JEI should display the relevant pages and recipes when 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, and Immersive Portals when available. Opening a book should not change 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 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. diff --git a/gradle.properties b/gradle.properties index 20b3346..9a166d5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ mod_name=Rivencraft # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-3.0-or-later # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.5.8.15 +mod_version=0.5.8.18 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=B6nTjh4b diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeBlockInstability.java b/src/main/java/net/dnorberg/rivencraft/book/AgeBlockInstability.java index 43e7aae..853e111 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeBlockInstability.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeBlockInstability.java @@ -3,8 +3,10 @@ package net.dnorberg.rivencraft.book; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import net.dnorberg.rivencraft.Rivencraft; 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.chunk.LevelChunk; 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.server.ServerStoppingEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent; @@ -24,7 +27,19 @@ import net.neoforged.neoforge.event.tick.ServerTickEvent; public final class AgeBlockInstability { // 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. - private static volatile Map weights; + private static final Map VANILLA_WEIGHTS = weights(); + private static final Map 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 SAMPLES_PER_TICK = 4; private static final int GENERATION_INTERVAL_TICKS = 20; @@ -38,15 +53,73 @@ public final class 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) { + if (VANILLA_WEIGHTS.containsKey(id)) return true; + if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false; + if (!BuiltInRegistries.BLOCK.containsKey(id)) return false; + var block = BuiltInRegistries.BLOCK.get(id); + return eligibleMaterial(block) && (configuredOverride(id) != null || inferredModdedWeight(id, block) > 0); + } - private static Map watchedWeights() { - Map current = weights; - if (current != null) return current; - synchronized (AgeBlockInstability.class) { - if (weights == null) weights = weights(); - 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 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 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) { @@ -174,10 +247,14 @@ public final class AgeBlockInstability { if (ageChunks < AgeBlockProfileSavedData.MIN_AGE_SAMPLES || baselineChunks < AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0; double result = 0; - for (var entry : watchedWeights().entrySet()) { - double excess = age.getOrDefault(entry.getKey(), 0L) / (double) ageChunks - - 1.05 * baseline.getOrDefault(entry.getKey(), 0L) / (double) baselineChunks; - if (excess > 0) result += excess * entry.getValue(); + Set watched = new HashSet<>(age.keySet()); + watched.addAll(baseline.keySet()); + for (ResourceLocation id : watched) { + 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)); } @@ -237,13 +314,6 @@ public final class AgeBlockInstability { add(values, 4000, "ancient_debris"); add(values, 60, "iron_block", "raw_iron_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); } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeBlockProfileSavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeBlockProfileSavedData.java index c832d10..2f45267 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeBlockProfileSavedData.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeBlockProfileSavedData.java @@ -15,6 +15,7 @@ import net.minecraft.world.level.saveddata.SavedData; /** Persistent, bounded samples of generated block abundance for one dimension. */ public final class AgeBlockProfileSavedData extends SavedData { + private static final int PROFILE_VERSION = 2; static final int MIN_AGE_SAMPLES = 401; static final int MIN_BASELINE_SAMPLES = 500; static final int MAX_SAMPLES = 1024; @@ -43,7 +44,7 @@ public final class AgeBlockProfileSavedData extends SavedData { if (section.hasOnlyAir()) continue; section.getStates().count((state, amount) -> { 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); @@ -59,6 +60,7 @@ public final class AgeBlockProfileSavedData extends SavedData { @Override public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + tag.putInt("Version", PROFILE_VERSION); ListTag entries = new ListTag(); for (var sample : samples.entrySet()) { CompoundTag entry = new CompoundTag(); @@ -74,6 +76,9 @@ public final class AgeBlockProfileSavedData extends SavedData { static AgeBlockProfileSavedData load(CompoundTag tag, HolderLookup.Provider registries) { 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); for (int index = 0; index < Math.min(entries.size(), MAX_SAMPLES); index++) { CompoundTag entry = entries.getCompound(index); @@ -81,7 +86,7 @@ public final class AgeBlockProfileSavedData extends SavedData { Map counts = new HashMap<>(); for (String key : blocks.getAllKeys()) { 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)); } data.add(new ChunkPos(entry.getLong("Chunk")), counts); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java b/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java index 702c766..6f93598 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java @@ -10,7 +10,17 @@ public final class AgeEnvironmentCompiler { public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits 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.Weather weather = seeded.weather(); double period = seeded.celestialPeriod(); @@ -73,10 +83,45 @@ public final class AgeEnvironmentCompiler { } 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); } + 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) { if (effects.equals(BuiltinDimensionTypes.NETHER_EFFECTS)) return 0xFF6B2725; if (effects.equals(BuiltinDimensionTypes.END_EFFECTS)) return 0xFF211835; diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java b/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java index 7290170..cf8d969 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java @@ -18,7 +18,7 @@ import net.minecraft.resources.ResourceLocation; public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List symbols, AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection, List activeSymbolIndices, int writingInstability) { - public static final int CURRENT_VERSION = 3; + public static final int CURRENT_VERSION = 4; public AgeGenerationPlan { if (version < 1 || version > CURRENT_VERSION) { diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java b/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java index 834db41..9ea9631 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java @@ -56,13 +56,20 @@ public record AgeInstability(int version, int score, List breakdow contributions.add(new Contribution("random_baseline", -1, 0, "Page-free or Link-Panel-only Age")); } else { contributions.add(new Contribution("authored_baseline", -1, 0, "Authored Age")); - WritingGrammar.Result parsed = WritingGrammar.parse(plan.writing()); - if (!parsed.canCreate() || parsed.writingInstability() != plan.writingInstability()) { - throw new IllegalArgumentException("Writing changed between Age compilation and scoring"); - } - for (WritingGrammar.Diagnostic diagnostic : parsed.diagnostics()) { - contributions.add(new Contribution(diagnostic.code().name().toLowerCase(java.util.Locale.ROOT), - diagnostic.index(), diagnostic.instability(), diagnostic.message())); + 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()); + if (!parsed.canCreate() || parsed.writingInstability() != plan.writingInstability()) { + throw new IllegalArgumentException("Writing changed between Age compilation and scoring"); + } + for (WritingGrammar.Diagnostic diagnostic : parsed.diagnostics()) { + contributions.add(new Contribution(diagnostic.code().name().toLowerCase(java.util.Locale.ROOT), + diagnostic.index(), diagnostic.instability(), diagnostic.message())); + } } addAuditedSymbolCosts(plan, registries, contributions); } diff --git a/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java index 9999307..fabe366 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java @@ -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) { return WritingEntry.symbol(new SymbolReference(ResourceLocation.fromNamespaceAndPath("rivencraft", path))); } diff --git a/src/main/java/net/dnorberg/rivencraft/book/Milestone6RegressionGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/Milestone6RegressionGameTests.java index 7ddafc5..b10a02b 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/Milestone6RegressionGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/Milestone6RegressionGameTests.java @@ -109,6 +109,33 @@ public final class Milestone6RegressionGameTests { 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") public static void blockProfileSamplesPersistWithoutDoubleCounting(GameTestHelper helper) { var data = new AgeBlockProfileSavedData(); @@ -121,6 +148,11 @@ public final class Milestone6RegressionGameTests { helper.getLevel().registryAccess()), helper.getLevel().registryAccess()); helper.assertValueEqual(restored.count(), 1, "The profiled chunk set 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(); } } diff --git a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java index 2c547a0..9bd7213 100644 --- a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java +++ b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java @@ -155,6 +155,8 @@ public final class RivencraftCommands { source.sendSuccess(() -> Component.literal("Block profile: Age " + ageSamples + "/401, Overworld baseline " + baselineSamples + "/500" + (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()); source.sendSuccess(() -> Component.literal("Runtime: " + (enabled ? "Age enabled" : "Age disabled") diff --git a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java index 5bcb74f..c3478e7 100644 --- a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java +++ b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java @@ -1,6 +1,7 @@ package net.dnorberg.rivencraft.config; import java.util.HashMap; +import java.util.List; import java.util.Map; import net.minecraft.resources.ResourceLocation; 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 ACCELERATED_MAX_CHUNKS; private static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS; + private static final ModConfigSpec.ConfigValue> MODDED_MATERIAL_OVERRIDES; private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_ENABLED; private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_PLAYERS; 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.") .defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536); 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.of(), () -> "examplemod:rare_ore=4000", + RivencraftConfig::validModdedMaterialOverride); + builder.pop(); builder.push("instability_status_effects"); STATUS_EFFECTS_ENABLED = builder.comment("Allow bounded instability status effects in loaded Ages.") .define("enabled", true); @@ -236,6 +245,22 @@ public final class RivencraftConfig { public static int instabilityMaxChunks() { return INSTABILITY_MAX_CHUNKS.get(); } public static int acceleratedMaxChunks() { return ACCELERATED_MAX_CHUNKS.get(); } public static int acceleratedMaxAttempts() { return ACCELERATED_MAX_ATTEMPTS.get(); } + public static List 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 statusEffectsPlayers() { return STATUS_EFFECTS_PLAYERS.get(); } public static boolean statusEffectsNonplayers() { return STATUS_EFFECTS_NONPLAYERS.get(); } diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java index 682e63d..afc7d28 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java @@ -87,6 +87,12 @@ public final class WritingGrammarGameTests { WritingGrammar.Result repeatedModifiers = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(periodHalf), WritingEntry.symbol(periodFull), WritingEntry.symbol(celestial)); 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), WritingEntry.symbol(terrain)), WritingGrammar.Code.DUPLICATE); 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"); assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(weather), WritingEntry.symbol(terrain)), WritingGrammar.Code.OUT_OF_ORDER); - assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial), - WritingEntry.symbol(periodHalf)), WritingGrammar.Code.MISPLACED_MODIFIER); + WritingGrammar.Result afterCelestial = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial), + 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), WritingEntry.symbol(plains)), WritingGrammar.Code.OUT_OF_ORDER); assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains), diff --git a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java index c03a1c1..74b7a38 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java @@ -40,6 +40,7 @@ public final class WritingGrammar { if (writing.entries().isEmpty()) return new Result(symbols, diagnostics); EnumMap singular = new EnumMap<>(Role.class); + EnumMap pendingModifiers = new EnumMap<>(Role.class); int panels = 0; int lastStage = -1; int biomeCount = 0; @@ -72,6 +73,13 @@ public final class WritingGrammar { Role role = semantics.orElseThrow().role(); ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow()); 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) { // A material page without a following feature receives a seeded compatible // feature when the Age plan is compiled. @@ -107,8 +115,8 @@ public final class WritingGrammar { } if (role == Role.BIOME) biomeCount++; int stage = stage(role); - if (stage < lastStage) { - diagnostics.add(new Diagnostic(modifier(role) ? Code.MISPLACED_MODIFIER : Code.OUT_OF_ORDER, + if (stage < lastStage && !modifier(role)) { + diagnostics.add(new Diagnostic(Code.OUT_OF_ORDER, index, "Move this " + role.id() + " page before later grammar categories")); } else { lastStage = stage; @@ -116,6 +124,10 @@ public final class WritingGrammar { } if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1, "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); ParsedSymbol controller = singular.get(Role.BIOME_CONTROLLER); @@ -174,14 +186,14 @@ public final class WritingGrammar { public enum Code { 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, UNSUPPORTED_COMBINATION; public Severity severity() { return switch (this) { 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; }; } @@ -205,7 +217,7 @@ public final class WritingGrammar { public int instability() { return switch (code) { case DUPLICATE, CONFLICT -> 500; - case OUT_OF_ORDER, MISPLACED_MODIFIER -> 100; + case OUT_OF_ORDER, MISPLACED_MODIFIER, DANGLING_MODIFIER -> 100; default -> 0; }; }