From 86cda69d11296dfe1a1ba10f99de0e001c54695d Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Thu, 8 Oct 2026 14:10:42 -0400 Subject: [PATCH] Audit Legacy instability and allow unstable supported writing Persist repaired Age writing and provisional instability penalties, retain fatal diagnostics for unsupported content, and hide TerraBlender's deferred placeholder biome from creative and JEI. --- docs/LEGACY_INSTABILITY_AUDIT.md | 62 +++++++++++++++++++ docs/PORTING.md | 4 +- docs/WRITING_GRAMMAR.md | 6 +- gradle.properties | 2 +- .../block/entity/BookBinderBlockEntity.java | 2 +- .../book/AgeEnvironmentCompiler.java | 8 +-- .../rivencraft/book/AgeFeatureSelection.java | 2 +- .../rivencraft/book/AgeGenerationPlan.java | 60 ++++++++++++++---- .../rivencraft/book/AgeRuntimeProfiles.java | 12 ++-- .../rivencraft/book/AgeSeasonMode.java | 4 +- .../book/EnvironmentWritingGameTests.java | 6 +- .../client/screen/BookBinderScreen.java | 5 +- .../gametest/BookBinderGameTests.java | 19 +++--- .../rivencraft/gametest/CoreGameTests.java | 5 ++ .../gametest/WritingGrammarGameTests.java | 50 ++++++++++++--- .../dnorberg/rivencraft/item/PageItem.java | 1 + .../rivencraft/menu/BookBinderMenu.java | 18 ++++-- .../rivencraft/writing/BiomeSymbolPages.java | 7 +++ .../rivencraft/writing/WritingGrammar.java | 55 +++++++++++++++- .../assets/rivencraft/lang/en_us.json | 8 +-- 20 files changed, 274 insertions(+), 62 deletions(-) create mode 100644 docs/LEGACY_INSTABILITY_AUDIT.md diff --git a/docs/LEGACY_INSTABILITY_AUDIT.md b/docs/LEGACY_INSTABILITY_AUDIT.md new file mode 100644 index 0000000..6fb0058 --- /dev/null +++ b/docs/LEGACY_INSTABILITY_AUDIT.md @@ -0,0 +1,62 @@ +# Mystcraft Legacy instability audit (Milestone 6, issue #63) + +This is a behavior contract for Milestone 6, **not a completed runtime instability system**. It compares the user-provided 0.13.7.06 class files at `/home/dnorberg/Documents/mystcraft-src/mystcraft-1.12.2-0.13.7.06/` with [Mystcraft Legacy source at commit `9bc8ddc`](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae). Values below are source defaults, not a claim that every historical config or server used them. Rivencraft 0.5.1.0 saves a provisional writing-instability score and repaired active-page order, but has no hazard loop yet. + +## Score, grammar, and selection in Legacy + +Legacy's [`AgeController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java) calculates `debug + symbol + block + book/base + bonus`, then applies difficulty 0/1/2/3 multipliers of 0.25/0.5/1/1.75. Global instability is enabled by default; an Age also has its own enable flag. The unmodified score is not simply “number of bad pages.” Block profiling may delay a usable score: `blockinstability` is null until **more than** 400 Age chunks are profiled, and `getInstabilityScore()` returns zero until then. It is recalculated after additional profiling. [`ChunkProfiler`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/profiling/ChunkProfiler.java) compares watched block abundance with a baseline, subtracting only positive excess above each baseline. The baseline is profiled or read from balance config; [`InstabilityDataCalculator`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/profiling/InstabilityDataCalculator.java) sets its default tolerance to 1.05, and default `useconfigs=false`. We should not copy its 400-chunk profiling delay or dynamic block scan into 1.21.1 without a separate performance case. + +| Input | Verified Legacy behavior | Rivencraft M6 interpretation | +| --- | --- | --- | +| Missing required controller | Terrain, biome controller, weather, and lighting are generated when missing. `InstabilityData.missing.controller = 0`; lighting explicitly adds zero. Missing pages themselves are not a penalty. | Keep Age-seeded completion. No penalty solely for an omitted branch. | +| Repeated same symbol | `addSymbol` increments a per-`IAgeSymbol` count and adds that symbol's `instabilityModifier(count)`. This method is symbol-specific; there is no universal “duplicate = N” rule. | Distinguish legal repeated features/materials from repeated single-choice pages. Do not add a flat duplicate penalty to every occurrence. | +| Extra/conflicting controller | Registering a second terrain, biome, weather, or lighting controller adds `InstabilityData.extra.controller = 500`; the **last registered** controller replaces the prior one. This is separate from each symbol's own modifier. | Eligible supported conflicts may become unstable rather than fatal in #65, with deterministic winner and a saved penalty. | +| Dangling modifier | An unconsumed modifier contributes its `dangling` value; a default `Modifier` has `dangling=100`. Replacing a pending modifier charges the old one. `clearModifiers()` charges 20% (`clearPercentage=0.20`) of each pending value, with Java integer compound-assignment truncation. | Record the ordered offending page and a reason; calculate a deterministic penalty from the consumed/leftover modifier semantics, not from GUI position alone. | +| Unsupported/unmatched page | Legacy logs unmatched symbols during reconstruction and skips their logic; its `AgeData.getBaseInstability()` still has a TODO for calculating instability from pages. This is not a sound compatibility contract. | Remains fatal for *new* books until semantic and runtime support exists; never silently erase the page or pretend its effect applied. Existing saved books retain their IDs. | +| Other penalties/bonuses | `AgeController.addInstability(int)` is available to symbol logic. The base/book score is persisted as a `short`; Legacy's [`InstabilityBonusManager`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/bonus/InstabilityBonusManager.java) also has dynamic player death/behavior components. | Persist a versioned score breakdown for Age writing and environmental contributions. Do not infer death/troll mechanics from the mere existence of Legacy classes. | + +Source for controller and modifier defaults: [`InstabilityData`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityData.java), [`AgeController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java). The local 0.13.7.06 `Modifier.class` confirms `dangling_default=100`. Legacy grammar **ranks** weight which missing symbols are selected; they are not a direct instability score or deck tier. See [grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) and [random weights](RANDOM_AGE_WEIGHTS.md). + +[`InstabilityController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityController.java) rounds its working score toward zero to the current smallest registered positive provider cost (500 by default), then rebuilds active effects when that rounded score changes. Each seeded deck preserves its saved card order; new cards are shuffled with the Age seed. For each deck it subtracts the deck entry cost, then walks cards in order, paying each provider activation cost until the next card cannot be afforded. A provider's occurrences become its level. **Deck costs are gates, not mutually exclusive severity bands**: at high score multiple decks can contribute effects. Provider `.enabled` config can remove cards. The actual effect set cannot be inferred from a score alone without the saved deck orders and enabled providers ([`Deck`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/Deck.java), [`InstabilityManager`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityManager.java)). + +| Legacy deck | Entry cost | Examples of registered cards | +| --- | ---: | --- | +| basic | 0 | enemy regeneration/resistance, fatigue, hunger, poison, slowness, weakness | +| harsh | 2,500 | stronger/global potions, burning, lightning, wither | +| destructive | 10,000 | crumble, explosions, lightning, meteors, wither | +| eating | 15,000 | colored decay, blindness, nausea, wither | +| death | 20,000 | global blindness/nausea/wither, colored decay | + +These entry costs are from [`InstabilityData.initialize()`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityData.java), not guaranteed first-activation scores: each card also costs 500–5,000 depending on provider. A zero-cost basic deck still needs enough score to buy its first card. `stability.*` numbers in that file are provider activation costs, despite the misleading field name. + +## Effect inventory and modern policy + +[`InstabilityData.initialize()`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityData.java) is the authoritative list of *registered* providers. Some implemented classes are commented out there; their existence does not mean they occur normally. Each Legacy environmental effect is called for chunks that the Age's runtime processes, often with its own chance. No per-Age global work budget is visible in these effect classes. + +| Family | Legacy behavior | M6 disposition | +| --- | --- | --- | +| Player and hostile-mob potions | Local/global blindness, fatigue, hunger, nausea, poison, slowness, weakness, wither; hostile regeneration/resistance ([`EffectPotion`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectPotion.java)). | Implement in #67 with server-only, Age-scoped entity targeting and rate limits. No effects in preview/snapshot worlds. | +| Colored decay | Blue, purple, red, white providers place advancing decay and extra-tick it; black provider is commented out. Decay handlers differ in spread/consumption ([providers](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/providers), [handlers](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/decay)). | Implement bounded, reversible-by-admin world mutation in #68; exact 1.12 spread/update rates require adaptation and tests. Exclude protected spawn/portal areas. Black/green/yellow are **not** proven active Legacy providers. | +| Lightning and explosions | Random per-chunk strike/explosion attempts ([`EffectLightning`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectLightning.java), [`EffectExplosions`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectExplosions.java)). | Implement in #69 with explicit block-damage and fire policy; no effect outside the Age. | +| Meteor, scorching | Meteor entity spawn and sky-exposed entity ignition ([`EffectMeteor`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectMeteor.java), [`EffectScorched`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectScorched.java)). | #70: modern meteor entity/impact needs an explicit damage budget; scorching can use modern fire/damage checks. Legacy `meteor/roar.ogg` is available. | +| Crumble and erosion | Crumble maps blocks to weaker states; erosion spreads adjacent fluids and has extra lake-generation methods ([`EffectCrumble`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectCrumble.java), [`EffectErosion`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectErosion.java)). | #70: bounded block palette and protected-area checks. Erosion's provider is commented out in Legacy registration: treat it as deliberate opt-in adaptation, not baseline parity. | +| Extra ticks | Registered alongside blue/purple/red/white decay, targeting decay blocks ([`EffectExtraTicks`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectExtraTicks.java)). | Implement only as a bounded decay helper in #68; do not accelerate arbitrary blocks/block entities. | +| Other dormant code | Black decay, global burning, bedrock crumble, erosion provider are commented out. | Defer unless a later issue explicitly requests them. An authored symbol requiring one must fail clearly until supported. | + +Assets in the supplied local tree: `assets/mystcraft/textures/blocks/decay_{black,blue,green,purple,red,white,yellow}.png`, corresponding decay block/item model JSON, and `assets/mystcraft/sounds/entity/meteor/roar.ogg`. Before importing, review attribution/license and fit modern 16×16 assets. Texture availability does not make an effect active. Use current Minecraft effects/sounds where adaptation is cleaner; no new artwork is needed for this audit. + +## Rivencraft boundary and proposed safety contract + +[`WritingGrammar`](../src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java) treats `INCOMPLETE` and supported writing defects as warnings. `AgeGenerationPlan.compile()` freezes the original writing, repaired active-page indexes, and a provisional writing-instability score before world generation. #64 still needs a full versioned score breakdown and saved provider/deck choice so reloads do not reroll effects. The plan must not mutate an already-created Age when a data pack changes. Server-side settings may disable execution, but should not rewrite the saved authored score. + +For #65, **eligible-to-become-unstable** means a known, supported page whose semantics and resulting world are still deterministic: repeated/conflicting single-choice pages, an otherwise valid misplaced modifier, and excess supported symbols within the Binder capacity. The initial 0.5.1.0 implementation retains every original page, uses the last single-choice page, sorts active categories, and saves 500 per extra/conflicting choice or 100 per misplaced page. These are provisional Rivencraft values, not a full port of every `IAgeSymbol.instabilityModifier`. Missing branches remain completion warnings; legal repeated feature/material pages are not automatically errors. **Remain fatal**: missing/duplicate/misordered Link Panel, over-capacity book, unresolved or schema-v1 display-only symbol, unsupported weather/angle/runtime profile/biome/feature, unavailable required optional mod, and any combination the compiler cannot represent safely. This intentionally differs from Legacy's silent unmatched-symbol skip. #65 still needs the final symbol-specific penalty table and tests. + +Proposed server defaults for #66–#72 (to confirm during implementation): instability scoring on for newly created Ages; passive entity effects on; world-mutating hazards **off by default** until an operator opts in; explicit per-Age toggle and separate per-family switches. Enforce a central work budget rather than letting every loaded chunk and provider roll independently: default at most one hazard attempt per Age per 20 ticks, one block mutation per successful attempt, and no forced chunk loads. Budgets should be configurable with hard upper bounds, including active Ages, target radius, tick interval, block changes/explosions/meteor impacts, entity effect attempts, and decay spread. Never target another dimension, unloaded chunk, book preview/snapshot, protected spawn/link platform, or portal. Stop safely when the Age unloads or settings change. These are **Rivencraft proposals**, not Legacy defaults or code that exists today; implementation issues must test/adjust them, especially multiple concurrent Ages. Expose score breakdown, selected effects, enablement, and budget suppression in server diagnostics (#72). + +M6 owns scoring, selection, effects, controls, and regression. M7 still owns portal/transport and broader polish; M6 must not depend on new M7 structures. An illegal biome/terrain combination that is presently unrepresentable remains a generation failure until a separate, safe compiler path exists; instability must not be used to disguise absent worldgen support. Existing pre-release saves need explicit migration behavior in #73 rather than a silent rescore. + +## Verification and open questions + +- Inspected pinned source for `AgeController`, `AgeData`, `GrammarTree`, `InstabilityData`, `InstabilityManager`, `InstabilityController`, `Deck`, all active provider families, effect classes, `ChunkProfiler`, and `InstabilityDataCalculator`; checked matching local 0.13.7.06 class names and `Modifier.dangling_default` with `javap`. +- The supplied extraction has no generated user balance config; registered defaults can be overridden or providers disabled. There is no single universal instability value for “wrong order” or “duplicate page.” `IAgeSymbol.instabilityModifier(count)` implementations and runtime configs determine the final Legacy value. +- Exact Legacy visual/decay behavior, dynamic bonus registration, and full 1.12 block-baseline parity need focused checks when their slices are implemented. Do not label proposed modern caps as source facts. diff --git a/docs/PORTING.md b/docs/PORTING.md index c103971..45325e3 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -42,7 +42,9 @@ acceptance issue and all required implementation issues are complete. TerraBlender and Serene Seasons integration. **Complete for the supported subset.** See the [acceptance record](MILESTONE_5_ACCEPTANCE.md) and [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md). -6. **Instability** — deterministic validation and server-safe effects. +6. **Instability** — deterministic validation and server-safe effects. Start with the + [Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md), which separates + source behavior from proposed 1.21.1 safety limits. 7. **Structures and polish** — libraries, crystals, portals, recipes, models, sounds, advancements, compatibility, and migration tests. diff --git a/docs/WRITING_GRAMMAR.md b/docs/WRITING_GRAMMAR.md index 926dfe4..c1695ac 100644 --- a/docs/WRITING_GRAMMAR.md +++ b/docs/WRITING_GRAMMAR.md @@ -6,9 +6,9 @@ New Binder writing has at most one Link Panel plus 27 symbol pages. The Link Pan The supported order follows the representable subset of the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md): terrain; zero or more biome pages; biome controller; weather; season; lighting; sky effects; zero or more period or phase modifiers; one celestial page; then zero or more features, each optionally preceded by a compatible material page. Biome pages precede the controller that consumes them, and celestial modifiers precede the celestial page. Terrain, controller, weather, season, lighting, sky, and celestial are single-choice categories; identical repeats are duplicates and differing values conflict. Repeated modifiers, biome pages, feature pages, and material pages are intentional and retain their order. An unpaired material page receives a seeded compatible feature during compilation. Angle modifiers remain recognized in the schema but are rejected at binding until a sky renderer can apply sun tilt. -Missing terrain, controller, weather, lighting, celestial, or the controller's minimum biome count is an **incomplete** warning, not an error: the compiler retains seed-selected traits for missing branches. An omitted terrain page uses a seed-selected profile compatible with authored feature pages, so changing the Age name cannot accidentally select Flat, Void, or Cave terrain for Rocks or Lakes. If no profile can support the writing, binding still rejects it. Native consumes no authored biome pages; Single accepts one; Tiled/Grid and Tiny–Huge require two, with seeded filler when pages are missing. Unknown IDs, schema-v1 display-only symbols, wrong ordering, misplaced modifiers, duplicates in single-choice categories, conflicts, missing/extra Link Panels, and oversized writing are errors. The Binder disables output and exposes the first error on the result-slot tooltip. When that error belongs to a page, its thumbnail is tinted and outlined red, the strip scrolls to it, and hovering it shows the diagnostic. This maps the parser's compact entry position back through any empty page positions. Rejected inputs remain removable. Binding an unwritten Descriptive Book rechecks its server-owned writing and reports the first error without creating an Age. A terrain symbol whose profile is unavailable is also rejected before Binder inputs are consumed. +Missing terrain, controller, weather, lighting, celestial, or the controller's minimum biome count is an **incomplete** warning, not an error: the compiler retains seed-selected traits for missing branches. An omitted terrain page uses a seed-selected profile compatible with authored feature pages, so changing the Age name cannot accidentally select Flat, Void, or Cave terrain for Rocks or Lakes. If no profile can support the writing, binding still rejects it. Native consumes no authored biome pages; Single consumes one; Tiled/Grid and Tiny–Huge require two, with seeded filler when pages are missing. From 0.5.1.0, known misplaced or repeated pages no longer block creation: the book keeps its original order, while the server repairs category order, uses the last single-choice controller, and stores a writing-instability score in the Age plan. A conflicting/repeated choice adds 500; an out-of-order page or misplaced modifier adds 100. Native ignores surplus authored biome pages and Single uses the first, with the conflict recorded as instability. These are Rivencraft's provisional penalties, not a claim of exact Legacy values for every symbol. Incorrectly placed pages are **not** red-highlighted; their warning remains available as a tooltip. Unknown IDs, schema-v1 display-only symbols, unavailable dependencies/profiles, unsupported semantic effects or feature/material pairs, missing/extra Link Panels, and oversized writing still reject a new Age. The Binder disables output for those fatal errors and shows a red page indication where applicable. Rejected inputs remain removable. -The plan stores the original writing order, the authoritative semantic value of each resolved symbol, the Age seed, and a copy of the selected runtime profile. An explicit terrain page overrides random terrain selection; an omitted terrain uses a seed-selected compatible profile. The built-in Standard, Amplified, Cave, Island, Flat, and Void terrain pages select their matching generation resources. Age loading uses the frozen runtime profile and does not need the original symbol definition or runtime-profile JSON, though its referenced noise, biome-preset, and dimension-type registry entries must remain available. Existing bound books without plans are not reinterpreted. Page-free and Link-Panel-only books keep the pre-existing random path. Biome and environment pages affect new Ages, and the supported feature subset below is compiled into the same frozen plan; instability remains Milestone 6 work. +The plan stores the original writing order, the authoritative semantic value of each resolved symbol, the active repaired-page indexes, the writing-instability score, the Age seed, and a copy of the selected runtime profile. An explicit terrain page overrides random terrain selection; an omitted terrain uses a seed-selected compatible profile. The built-in Standard, Amplified, Cave, Island, Flat, and Void terrain pages select their matching generation resources. Age loading uses the frozen runtime profile and does not need the original symbol definition or runtime-profile JSON, though its referenced noise, biome-preset, and dimension-type registry entries must remain available. Existing bound books without plans are not reinterpreted. Page-free and Link-Panel-only books keep the pre-existing random path. Biome and environment pages affect new Ages, and the supported feature subset below is compiled into the same frozen plan. Runtime effects for a nonzero writing-instability score are not implemented yet. ## Supported feature pages (issue #35) @@ -22,4 +22,4 @@ Crystal Formations are an authored, material-free feature on exposed solid terra Star Fissures are a material-free feature on every terrain profile, including Cave and Void. The fissure is generated once beside the first resolved spawn, using Legacy's spawn-chunk offset and 10–17 paired, irregular X-span rows running along +Z. Legacy's y=0 portal floor maps to the modern dimension's minimum build height, replacing lower bedrock if present. The rift opens to the sky on ordinary terrain; in Cave terrain it opens into the cavern but stops below the upper bedrock roof. Water in the carved columns is cleared before the portal is placed. Its non-solid portal links to the Overworld spawn with the Legacy fissure sound; the animated starfield is drawn with Minecraft's current End Portal renderer, which uses the same end-sky/end-portal art as Legacy's custom renderer. Random Ages sample Legacy's default small-feature grammar for fissure selection: the stop/one/repeat branches have rank-derived weights 1:1:3, and the fissure has weight 1 of 9 among its small-feature rules. The remainder of random feature selection still follows Rivencraft's current pipeline, rather than running Legacy's entire Age grammar. -The Rivencraft symbol-page creative tabs read a generated index of bundled symbol definitions and group Pages by each definition's category, except biome-controller roles, which appear in a separate Biome Generation tab rather than the Biome tab. Adding another valid JSON symbol under `src/main/resources/data/rivencraft/rivencraft/symbols/` includes its page in the matching category tab on the next build; a new bundled category receives its own tab. Registry-derived Block and Fluid Pages have separate tabs and both appear in optional JEI. Link Pages have a separate tab. Blank Pages appear in neither the creative tabs nor JEI's ingredient list. The server catalog still decides whether a page is valid for writing. External server data-pack symbols are not included in this bundled index. +The Rivencraft symbol-page creative tabs read a generated index of bundled symbol definitions and group Pages by each definition's category, except biome-controller roles, which appear in a separate Biome Generation tab rather than the Biome tab. Adding another valid JSON symbol under `src/main/resources/data/rivencraft/rivencraft/symbols/` includes its page in the matching category tab on the next build; a new bundled category receives its own tab. Registry-derived Block and Fluid Pages have separate tabs and both appear in optional JEI. Registry-derived biome pages share a creative/JEI feed; `terrablender:deferred_placeholder` is excluded because it is TerraBlender's internal staging biome, not a player-facing biome choice. Other modded biomes remain listed. Link Pages have a separate tab. Blank Pages appear in neither the creative tabs nor JEI's ingredient list. The server catalog still decides whether a page is valid for writing. External server data-pack symbols are not included in this bundled index. diff --git a/gradle.properties b/gradle.properties index 27ca8a5..9b7ba9c 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.0.0 +mod_version=0.5.1.1 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/block/entity/BookBinderBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java index 18f9718..0d0078e 100644 --- a/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java @@ -138,7 +138,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe List diagnostics = new ArrayList<>(parsed.diagnostics()); var terrain = parsed.symbols().stream() .filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.TERRAIN) - .findFirst(); + .reduce((first, last) -> last); if (terrain.isPresent()) { var profileId = ((SymbolSemantics.ResourceValue) terrain.orElseThrow().semantics().value()).id(); if (!AgeRuntimeProfiles.profiles().containsKey(profileId)) { diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java b/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java index 9d1397c..702c766 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java @@ -15,14 +15,14 @@ public final class AgeEnvironmentCompiler { AgeTraits.Weather weather = seeded.weather(); double period = seeded.celestialPeriod(); float phase = seeded.celestialPhase(); - boolean normalTime = plan.symbols().stream() + boolean normalTime = plan.activeSymbols().stream() .anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL); if (normalTime) period = 1.0D; boolean writtenPeriod = false; boolean writtenPhase = false; float phaseDegrees = 0.0F; - for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) { + for (AgeGenerationPlan.ResolvedSymbol symbol : plan.activeSymbols()) { SymbolSemantics semantics = symbol.semantics(); switch (semantics.role()) { case LIGHTING -> lighting = AgeTraits.Lighting.valueOf( @@ -61,7 +61,7 @@ public final class AgeEnvironmentCompiler { /** Vanilla effects are the sky styles the current renderer can honor exactly. */ public static ResourceLocation skyEffects(AgeGenerationPlan plan, ResourceLocation fallback) { if (plan == null) return fallback; - return plan.symbols().stream() + return plan.activeSymbols().stream() .filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SKY) .map(symbol -> ((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id()) .map(choice -> switch (choice) { @@ -73,7 +73,7 @@ public final class AgeEnvironmentCompiler { } public static boolean hasWrittenPhase(AgeGenerationPlan plan) { - return plan != null && plan.symbols().stream() + return plan != null && plan.activeSymbols().stream() .anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.PHASE_MODIFIER); } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java index 3c4f786..419898a 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java @@ -137,7 +137,7 @@ public final class AgeFeatureSelection { Map> materialOrders = new HashMap<>(); Map occurrences = new HashMap<>(); List resolved = new ArrayList<>(); - for (Choice choice : choices(plan.symbols().stream().map(symbol -> symbol.semantics()).toList(), + for (Choice choice : choices(plan.activeSymbols().stream().map(symbol -> symbol.semantics()).toList(), plan.runtimeProfile(), plan.ageSeed())) { if (choice.material().isPresent() || !isRock(choice.id()) && !isLake(choice.id()) diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java b/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java index c8018cc..7290170 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java @@ -16,8 +16,9 @@ import net.minecraft.resources.ResourceLocation; /** A frozen interpretation of authored pages. Later slices consume its resolved branches. */ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List symbols, - AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection) { - public static final int CURRENT_VERSION = 2; + AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection, + List activeSymbolIndices, int writingInstability) { + public static final int CURRENT_VERSION = 3; public AgeGenerationPlan { if (version < 1 || version > CURRENT_VERSION) { @@ -25,12 +26,14 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin } Objects.requireNonNull(writing, "writing"); symbols = List.copyOf(symbols); + activeSymbolIndices = List.copyOf(activeSymbolIndices); + if (writingInstability < 0) throw new IllegalArgumentException("Negative writing instability"); Objects.requireNonNull(runtimeProfile, "runtimeProfile"); if (runtimeProfile.schemaVersion() != AgeRuntimeProfile.CURRENT_SCHEMA_VERSION) { throw new IllegalArgumentException("Unsupported runtime profile snapshot version"); } - if (version == CURRENT_VERSION && biomeSelection == null) { - throw new IllegalArgumentException("Version 2 Age plan requires a biome selection snapshot"); + if (version >= 2 && biomeSelection == null) { + throw new IllegalArgumentException("Version 2+ Age plan requires a biome selection snapshot"); } for (ResolvedSymbol symbol : symbols) { if (symbol.index() < 0 || symbol.index() >= writing.entries().size() @@ -38,12 +41,31 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin throw new IllegalArgumentException("Resolved symbol does not match original page order"); } } + if (version >= 3 && !symbols.isEmpty() && activeSymbolIndices.isEmpty()) { + throw new IllegalArgumentException("Version 3 Age plan has no active symbols"); + } + if (activeSymbolIndices.stream().distinct().count() != activeSymbolIndices.size()) { + throw new IllegalArgumentException("Age plan repeats an active symbol index"); + } + for (int index : activeSymbolIndices) { + if (symbols.stream().noneMatch(symbol -> symbol.index() == index)) { + throw new IllegalArgumentException("Active symbol is not in the saved writing: " + index); + } + } } /** Existing version 1 plans retain their original biome behavior on reload. */ public AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List symbols, AgeRuntimeProfile runtimeProfile) { - this(version, ageSeed, writing, symbols, runtimeProfile, null); + this(version, ageSeed, writing, symbols, runtimeProfile, null, + symbols.stream().map(ResolvedSymbol::index).toList(), 0); + } + + /** Existing call sites and version-2 plans retain every resolved symbol. */ + public AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List symbols, + AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection) { + this(version, ageSeed, writing, symbols, runtimeProfile, biomeSelection, + symbols.stream().map(ResolvedSymbol::index).toList(), 0); } /** No plan is made for old page-free books or Link-Panel-only random Ages. */ @@ -52,7 +74,9 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin WritingGrammar.Result parsed = WritingGrammar.parse(writing); if (!parsed.canCreate()) throw new IllegalArgumentException(parsed.firstError().orElseThrow().message()); if (parsed.randomFallback()) return null; - ResourceLocation terrain = parsed.symbols().stream() + List active = parsed.activeSymbols(); + WritingGrammar.Result effective = new WritingGrammar.Result(active, parsed.diagnostics()); + ResourceLocation terrain = active.stream() .filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.TERRAIN) .map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()) .findFirst().orElse(null); @@ -64,23 +88,29 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin ResourceLocation selected = terrain == null ? seedProfile : terrain; AgeRuntimeProfile profile = AgeRuntimeProfiles.profiles().get(selected); if (profile == null) throw new IllegalArgumentException("Unsupported Age terrain profile " + selected); - AgeBiomeSelection biomes = AgeBiomeSelection.compile(parsed, traits, profile, ageSeed, registries); - AgeFeatureSelection.compile(parsed, profile, biomes, registries, ageSeed); + AgeBiomeSelection biomes = AgeBiomeSelection.compile(effective, traits, profile, ageSeed, registries); + AgeFeatureSelection.compile(effective, profile, biomes, registries, ageSeed); AgeGenerationPlan plan = new AgeGenerationPlan(CURRENT_VERSION, ageSeed, writing, parsed.symbols().stream().map(symbol -> new ResolvedSymbol( symbol.index(), symbol.reference(), symbol.semantics())).toList(), - profile, biomes); + profile, biomes, active.stream().map(WritingGrammar.ParsedSymbol::index).toList(), + parsed.writingInstability()); AgeEnvironmentCompiler.apply(plan, traits); AgeSeasonMode.validate(plan); return plan; } public ResourceLocation authoredTerrain() { - return symbols.stream().filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.TERRAIN) + return activeSymbols().stream().filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.TERRAIN) .map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()) .findFirst().orElse(null); } + public List activeSymbols() { + return activeSymbolIndices.stream().map(index -> symbols.stream() + .filter(symbol -> symbol.index() == index).findFirst().orElseThrow()).toList(); + } + public CompoundTag save() { CompoundTag tag = new CompoundTag(); tag.putInt("Version", version); @@ -106,6 +136,10 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin resolved.add(item); } tag.put("Resolved", resolved); + if (version >= 3) { + tag.putIntArray("ActiveSymbolIndices", activeSymbolIndices); + tag.putInt("WritingInstability", writingInstability); + } CompoundTag profile = new CompoundTag(); profile.putInt("Version", runtimeProfile.schemaVersion()); profile.putString("Terrain", runtimeProfile.terrain().name()); @@ -162,12 +196,16 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin selection = new AgeBiomeSelection(AgeTraits.BiomeDistribution.valueOf( biomeTag.getString("Distribution")), ids); } + List activeIndices = version >= 3 + ? java.util.Arrays.stream(tag.getIntArray("ActiveSymbolIndices")).boxed().toList() + : resolved.stream().map(ResolvedSymbol::index).toList(); return new AgeGenerationPlan(version, tag.getLong("AgeSeed"), new OrderedWriting(pages), resolved, new AgeRuntimeProfile(profile.getInt("Version"), AgeRuntimeProfile.Terrain.valueOf(profile.getString("Terrain")), ResourceLocation.parse(profile.getString("Noise")), ResourceLocation.parse(profile.getString("Biomes")), - ResourceLocation.parse(profile.getString("Dimension"))), selection); + ResourceLocation.parse(profile.getString("Dimension"))), selection, + activeIndices, version >= 3 ? tag.getInt("WritingInstability") : 0); } public record ResolvedSymbol(int index, SymbolReference reference, SymbolSemantics semantics) { diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeRuntimeProfiles.java b/src/main/java/net/dnorberg/rivencraft/book/AgeRuntimeProfiles.java index 44e3540..decf341 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeRuntimeProfiles.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeRuntimeProfiles.java @@ -69,22 +69,24 @@ public final class AgeRuntimeProfiles { public static ResourceLocation selectForWriting(long seed, OrderedWriting writing, RegistryAccess registries) { ResourceLocation selected = select(seed); WritingGrammar.Result parsed = WritingGrammar.parse(writing); - if (parsed.symbols().stream().anyMatch(symbol -> + if (parsed.activeSymbols().stream().anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)) return selected; - List features = parsed.symbols().stream() + List features = parsed.activeSymbols().stream() .filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE) .map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList(); - boolean hasMaterial = parsed.symbols().stream().anyMatch(symbol -> + boolean hasMaterial = parsed.activeSymbols().stream().anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.BLOCK); if ((features.isEmpty() && !hasMaterial) || PROFILES.isEmpty()) return selected; AgeRuntimeProfile initial = PROFILES.get(selected); if (initial != null && features.stream().allMatch(id -> AgeFeatureSelection.error(id, initial, registries) == null) - && AgeFeatureSelection.supportsUnpairedMaterials(parsed, initial)) return selected; + && AgeFeatureSelection.supportsUnpairedMaterials( + new WritingGrammar.Result(parsed.activeSymbols(), parsed.diagnostics()), initial)) return selected; List compatible = PROFILES.entrySet().stream() .filter(entry -> features.stream().allMatch(id -> AgeFeatureSelection.error(id, entry.getValue(), registries) == null) - && AgeFeatureSelection.supportsUnpairedMaterials(parsed, entry.getValue())) + && AgeFeatureSelection.supportsUnpairedMaterials( + new WritingGrammar.Result(parsed.activeSymbols(), parsed.diagnostics()), entry.getValue())) .map(Map.Entry::getKey).sorted().toList(); return compatible.isEmpty() ? selected : compatible.get(Math.floorMod(Long.hashCode(seed), compatible.size())); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeSeasonMode.java b/src/main/java/net/dnorberg/rivencraft/book/AgeSeasonMode.java index e5c1ec2..f8a4748 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeSeasonMode.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeSeasonMode.java @@ -35,7 +35,7 @@ public enum AgeSeasonMode { } public static boolean hasAuthoredPage(AgeGenerationPlan plan) { - return plan != null && plan.symbols().stream() + return plan != null && plan.activeSymbols().stream() .anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON); } @@ -49,7 +49,7 @@ public enum AgeSeasonMode { public static AgeSeasonMode from(AgeGenerationPlan plan) { if (plan == null) return SEASONS; - return plan.symbols().stream() + return plan.activeSymbols().stream() .filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON) .map(symbol -> valueOf(((SymbolSemantics.ChoiceValue) symbol.semantics().value()) .id().toUpperCase(Locale.ROOT))) diff --git a/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java index 6cef9c5..9999307 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java @@ -91,8 +91,10 @@ public final class EnvironmentWritingGameTests { AgeSeasonMode.NO_SEASONS, "The Age's seasonal policy must survive reload"); OrderedWriting conflict = new OrderedWriting(List.of(WritingEntry.linkPanel(), page("season/no_seasons"), page("season/eternal_winter"))); - helper.assertFalse(WritingGrammar.parse(conflict).canCreate(), - "Only one seasonal page may be used in an Age"); + helper.assertTrue(WritingGrammar.parse(conflict).canCreate(), + "A repeated seasonal page must be recoverable as unstable writing"); + helper.assertValueEqual(WritingGrammar.parse(conflict).writingInstability(), 500, + "The seasonal conflict must contribute instability"); helper.succeed(); } diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/BookBinderScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/BookBinderScreen.java index fa15c3f..ebc5e3f 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/BookBinderScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/BookBinderScreen.java @@ -109,7 +109,7 @@ public final class BookBinderScreen extends AbstractContainerScreen Component.translatable( "screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT), errorStripIndex + 1)).orElse(null); - if (menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty() + if ((menu.grammarWarning() || menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty()) && mouseX >= leftPos + 152 && mouseX < leftPos + 168 && mouseY >= topPos + 27 && mouseY < topPos + 43 && grammarTooltip != null) { @@ -169,7 +169,8 @@ public final class BookBinderScreen extends AbstractContainerScreen graphics.blit(NotebookScreen.symbolTexture(page), x + 1, y + 5, 24, 24, 0, 0, 128, 128, 128, 128); } - if (index == menu.grammarErrorStripIndex() && menu.grammarError().isPresent()) { + if (index == menu.grammarErrorStripIndex() && menu.grammarError().isPresent() + && !menu.grammarWarning()) { graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0x55FF2020); graphics.renderOutline(x, y, PAGE_WIDTH, PAGE_HEIGHT, 0xFFFF3030); } diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java index 46650cf..da8a7ab 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java @@ -108,21 +108,22 @@ public final class BookBinderGameTests { binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage()); binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage()); binder.setItem(BookBinderBlockEntity.FIRST_PAGE + 5, terrainPage()); - helper.assertFalse(binder.canAssemble(), "Duplicate terrain must not assemble a new Age"); - int errorEntry = binder.writingGrammar().firstError().orElseThrow().index(); + helper.assertTrue(binder.canAssemble(), "Duplicate terrain must create an unstable Age"); + int errorEntry = binder.writingGrammar().firstWarning().orElseThrow().index(); helper.assertValueEqual(errorEntry, 2, "Parser must identify the second authored symbol"); helper.assertValueEqual(binder.writingStripIndex(errorEntry), 6, - "A grammar error must highlight the correct strip page despite empty positions"); - helper.assertFalse(binder.consumeAssembly(), "Rejected grammar must not consume binder inputs"); + "An instability warning must highlight the correct strip page despite empty positions"); + helper.assertValueEqual(binder.writingGrammar().writingInstability(), 500, + "An extra controller must carry a persisted writing penalty"); helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER), - "Rejected grammar must leave the cover recoverable"); + "Warning preview must not consume the cover"); helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()), - "Rejected grammar must leave the Link Panel recoverable"); + "Warning preview must not consume the Link Panel"); helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 5).is(ModItems.PAGE.get()), - "Rejected grammar must leave the duplicate page removable"); + "Unstable writing must remain editable before assembly"); ItemStack removed = binder.takePage(5); - helper.assertTrue(removed.is(ModItems.PAGE.get()), "Player must be able to correct the duplicate page"); - helper.assertTrue(binder.canAssemble(), "Corrected partial writing must become assemblable"); + helper.assertTrue(removed.is(ModItems.PAGE.get()), "Player must be able to remove the duplicate page"); + helper.assertTrue(binder.canAssemble(), "Corrected partial writing must remain assemblable"); helper.succeed(); } diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java index 9330b80..59551f4 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java @@ -101,6 +101,11 @@ public final class CoreGameTests { helper.assertFalse(BiomeSymbolPages.symbolId(ResourceLocation.parse("example:orchard")) .equals(BiomeSymbolPages.symbolId(ResourceLocation.parse("another:orchard"))), "Equal biome paths from different mods must not share a page ID"); + helper.assertFalse(BiomeSymbolPages.isPublicPageBiome( + ResourceLocation.parse("terrablender:deferred_placeholder")), + "TerraBlender's internal staging biome must not appear as a creative or JEI page"); + helper.assertTrue(BiomeSymbolPages.isPublicPageBiome(ResourceLocation.parse("biomesoplenty:orchard")), + "Real modded biome pages must remain visible"); helper.succeed(); } diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java index 65bf298..682e63d 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java @@ -4,6 +4,9 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import net.dnorberg.rivencraft.book.AgeGenerationPlan; +import net.dnorberg.rivencraft.book.AgeGenerationProfiles; +import net.dnorberg.rivencraft.book.AgeTraits; import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.SymbolCatalog; import net.dnorberg.rivencraft.writing.SymbolDefinition; @@ -84,20 +87,42 @@ 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"); - assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain), + assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain), WritingEntry.symbol(terrain)), WritingGrammar.Code.DUPLICATE); - assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain), - WritingEntry.symbol(alternateTerrain)), WritingGrammar.Code.CONFLICT); - assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(weather), + WritingGrammar.Result replacedTerrain = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain), + WritingEntry.symbol(alternateTerrain)); + assertPenalty(helper, replacedTerrain, WritingGrammar.Code.CONFLICT); + helper.assertValueEqual(replacedTerrain.activeSymbols().getFirst().reference(), alternateTerrain, + "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); - assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial), + assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial), WritingEntry.symbol(periodHalf)), WritingGrammar.Code.MISPLACED_MODIFIER); - assertCode(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); - assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains), + assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains), WritingEntry.symbol(nativeBiomes)), WritingGrammar.Code.CONFLICT); - assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains), - WritingEntry.symbol(forest), WritingEntry.symbol(single)), WritingGrammar.Code.CONFLICT); + WritingGrammar.Result extraBiomes = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains), + WritingEntry.symbol(forest), WritingEntry.symbol(single)); + assertPenalty(helper, extraBiomes, WritingGrammar.Code.CONFLICT); + helper.assertValueEqual(extraBiomes.activeSymbols().stream().filter(symbol -> + symbol.semantics().role() == SymbolSemantics.Role.BIOME).count(), 1L, + "Single-biome distribution must consume only one authored biome"); + OrderedWriting repaired = writing(WritingEntry.linkPanel(), WritingEntry.symbol(weather), + WritingEntry.symbol(terrain), WritingEntry.symbol(ref("rivencraft:terrain/flat"))); + AgeGenerationPlan plan = AgeGenerationPlan.compile(repaired, 42L, AgeGenerationProfiles.STANDARD, + AgeTraits.select(42L), helper.getLevel().registryAccess()); + helper.assertValueEqual(plan.authoredTerrain(), ResourceLocation.parse("rivencraft:flat"), + "The last terrain controller must win without deleting earlier pages"); + helper.assertValueEqual(plan.writingInstability(), 600, + "An extra controller and repaired ordering must be counted once each"); + helper.assertValueEqual(plan.symbols().size(), 3, + "The saved plan must retain all authored pages, including the extra controller"); + AgeGenerationPlan loaded = AgeGenerationPlan.load(plan.save()); + helper.assertValueEqual(loaded.writingInstability(), 600, + "Writing instability must survive a saved-plan round trip"); + helper.assertValueEqual(loaded.activeSymbolIndices(), plan.activeSymbolIndices(), + "The effective writing order must not reroll on load"); WritingGrammar.Result repeatedFeatures = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(caves), WritingEntry.symbol(caves)); helper.assertTrue(repeatedFeatures.canCreate(), "Repeated feature pages must remain authorable"); @@ -142,4 +167,11 @@ public final class WritingGrammarGameTests { helper.assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.code() == code), "Writing must report " + code + " rather than silently dropping a page"); } + + private static void assertPenalty(GameTestHelper helper, WritingGrammar.Result result, WritingGrammar.Code code) { + helper.assertTrue(result.canCreate(), "Known writing defect " + code + " must not block Age creation"); + helper.assertTrue(result.writingInstability() > 0, "Known writing defect must contribute instability"); + helper.assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.code() == code), + "Writing must report " + code + " rather than silently dropping a page"); + } } diff --git a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index 965376b..eca097c 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -102,6 +102,7 @@ public final class PageItem extends Item { Set bundled = CREATIVE_SYMBOLS.keySet(); registries.lookupOrThrow(Registries.BIOME).listElements() .map(holder -> holder.key().location()) + .filter(BiomeSymbolPages::isPublicPageBiome) .filter(id -> !bundled.contains(BiomeSymbolPages.symbolId(id).getPath())) .sorted() .forEach(id -> pages.add(createCreativeBiomePage(id))); diff --git a/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java b/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java index c391243..ef8f8e9 100644 --- a/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java +++ b/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java @@ -56,15 +56,15 @@ public final class BookBinderMenu extends AbstractContainerMenu { }); addDataSlot(new DataSlot() { @Override public int get() { - return clientSide || binder == null ? grammarErrorCode : binder.writingGrammar().firstError() - .map(error -> error.code().ordinal()).orElse(-1); + return clientSide || binder == null ? grammarErrorCode : binderDiagnostic() + .map(diagnostic -> diagnostic.code().ordinal()).orElse(-1); } @Override public void set(int value) { grammarErrorCode = value; } }); addDataSlot(new DataSlot() { @Override public int get() { - return clientSide || binder == null ? grammarErrorStripIndex : binder.writingGrammar().firstError() - .map(error -> binder.writingStripIndex(error.index())).orElse(-1); + return clientSide || binder == null ? grammarErrorStripIndex : binderDiagnostic() + .map(diagnostic -> binder.writingStripIndex(diagnostic.index())).orElse(-1); } @Override public void set(int value) { grammarErrorStripIndex = value; } }); @@ -87,11 +87,21 @@ public final class BookBinderMenu extends AbstractContainerMenu { } public int selectedPage() { return selectedPage; } + private Optional binderDiagnostic() { + WritingGrammar.Result grammar = binder.writingGrammar(); + return grammar.firstError().or(grammar::firstWarning); + } public Optional grammarError() { WritingGrammar.Code[] codes = WritingGrammar.Code.values(); return grammarErrorCode >= 0 && grammarErrorCode < codes.length ? Optional.of(codes[grammarErrorCode]) : Optional.empty(); } + public boolean grammarWarning() { + return grammarError().map(code -> switch (code) { + case DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER -> true; + default -> false; + }).orElse(false); + } public int grammarErrorStripIndex() { return grammarErrorStripIndex; } public String title() { return binder == null ? "" : binder.title(); } public boolean setTitle(Player player, String title) { diff --git a/src/main/java/net/dnorberg/rivencraft/writing/BiomeSymbolPages.java b/src/main/java/net/dnorberg/rivencraft/writing/BiomeSymbolPages.java index 72c060f..4d7fa36 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/BiomeSymbolPages.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/BiomeSymbolPages.java @@ -9,9 +9,16 @@ import net.minecraft.resources.ResourceLocation; /** Registry-derived pages for biomes without hand-authored symbol JSON. */ public final class BiomeSymbolPages { private static final ResourceLocation CATEGORY = ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome"); + private static final ResourceLocation TERRABLENDER_DEFERRED_PLACEHOLDER = + ResourceLocation.fromNamespaceAndPath("terrablender", "deferred_placeholder"); private BiomeSymbolPages() { } + /** TerraBlender's internal staging biome is registered, but is not a player-authorable biome page. */ + public static boolean isPublicPageBiome(ResourceLocation biomeId) { + return !TERRABLENDER_DEFERRED_PLACEHOLDER.equals(biomeId); + } + public static ResourceLocation symbolId(ResourceLocation biomeId) { // Preserve old vanilla page IDs, while keeping modded namespaces distinct. return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome/" diff --git a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java index 0736822..2672ea4 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java @@ -1,6 +1,7 @@ package net.dnorberg.rivencraft.writing; import java.util.ArrayList; +import java.util.Comparator; import java.util.EnumMap; import java.util.List; import java.util.Objects; @@ -82,7 +83,7 @@ public final class WritingGrammar { ResourceLocation feature = ((SymbolSemantics.ResourceValue) parsed.semantics().value()).id(); if (!MATERIAL_FEATURES.contains(feature.getPath()) || !feature.getNamespace().equals("rivencraft")) { - diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingBlock.index(), + diagnostics.add(new Diagnostic(Code.UNSUPPORTED_FEATURE, pendingBlock.index(), "This feature cannot consume a material page")); } pendingBlock = null; @@ -94,7 +95,7 @@ public final class WritingGrammar { "This environment effect needs renderer or precipitation support")); } if (singular(role)) { - ParsedSymbol previous = singular.putIfAbsent(role, parsed); + ParsedSymbol previous = singular.put(role, parsed); if (previous != null) { Code code = previous.semantics().value().equals(parsed.semantics().value()) ? Code.DUPLICATE : Code.CONFLICT; @@ -183,7 +184,21 @@ public final class WritingGrammar { Objects.requireNonNull(message, "message"); } - public boolean error() { return code != Code.INCOMPLETE; } + public boolean error() { + return switch (code) { + case INCOMPLETE, DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER -> false; + default -> true; + }; + } + + /** The known writing defects have an explicit, provisional M6 score. */ + public int instability() { + return switch (code) { + case DUPLICATE, CONFLICT -> 500; + case OUT_OF_ORDER, MISPLACED_MODIFIER -> 100; + default -> 0; + }; + } } public record ParsedSymbol(int index, SymbolReference reference, SymbolSemantics semantics) { @@ -202,5 +217,39 @@ public final class WritingGrammar { public boolean randomFallback() { return symbols.isEmpty() && diagnostics.isEmpty(); } public boolean canCreate() { return diagnostics.stream().noneMatch(Diagnostic::error); } public Optional firstError() { return diagnostics.stream().filter(Diagnostic::error).findFirst(); } + public Optional firstWarning() { + return diagnostics.stream().filter(diagnostic -> diagnostic.instability() > 0).findFirst(); + } + public int writingInstability() { + return diagnostics.stream().mapToInt(Diagnostic::instability).sum(); + } + + /** Preserve the book's original entries, but compile a repaired Legacy-style order. */ + public List activeSymbols() { + EnumMap lastChoice = new EnumMap<>(Role.class); + for (ParsedSymbol symbol : symbols) { + if (singular(symbol.semantics().role())) lastChoice.put(symbol.semantics().role(), symbol); + } + List active = new ArrayList<>(); + for (ParsedSymbol symbol : symbols) { + if (!singular(symbol.semantics().role()) + || lastChoice.get(symbol.semantics().role()) == symbol) active.add(symbol); + } + active.sort(Comparator.comparingInt(symbol -> stage(symbol.semantics().role()))); + ParsedSymbol controller = lastChoice.get(Role.BIOME_CONTROLLER); + if (controller != null && controller.semantics().value() instanceof ChoiceValue choice) { + if (choice.id().equals("native")) { + active.removeIf(symbol -> symbol.semantics().role() == Role.BIOME); + } else if (choice.id().equals("single")) { + boolean retained = false; + for (int index = 0; index < active.size(); index++) { + if (active.get(index).semantics().role() != Role.BIOME) continue; + if (!retained) retained = true; + else active.remove(index--); + } + } + } + return List.copyOf(active); + } } } diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 6e5b4ef..c7e9143 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -47,10 +47,10 @@ "screen.rivencraft.book_binder.grammar.unsupported": "Page %s: this symbol needs semantic schema v2; update its data pack or remove it", "screen.rivencraft.book_binder.grammar.unsupported_profile": "Page %s: its Age profile is unavailable; restore the data pack or remove this page", "screen.rivencraft.book_binder.grammar.unsupported_environment": "Page %s: this weather or sky effect is not supported yet", - "screen.rivencraft.book_binder.grammar.duplicate": "Page %s: remove the duplicate symbol", - "screen.rivencraft.book_binder.grammar.conflict": "Page %s: replace the conflicting symbol", - "screen.rivencraft.book_binder.grammar.out_of_order": "Page %s: move this symbol before later categories", - "screen.rivencraft.book_binder.grammar.misplaced_modifier": "Page %s: move this modifier before its celestial page", + "screen.rivencraft.book_binder.grammar.duplicate": "Page %s: duplicate page adds instability; the last choice is used", + "screen.rivencraft.book_binder.grammar.conflict": "Page %s: conflicting page adds instability; the supported choice is used", + "screen.rivencraft.book_binder.grammar.out_of_order": "Page %s: incorrect order adds instability; the Age repairs the order", + "screen.rivencraft.book_binder.grammar.misplaced_modifier": "Page %s: misplaced modifier adds instability; the Age repairs its order", "screen.rivencraft.writing_desk.ink": "Ink: %s/%s", "screen.rivencraft.writing_desk.search": "Search", "screen.rivencraft.writing_desk.sort": "Sort pages by name (A–Z)",