Compare commits

...
3 Commits
Author SHA1 Message Date
dnorberg 5538ca6aa6 Render Legacy glyph pages and base-25 desk numerals
Build / build (push) Canceled after 0s
2026-09-30 13:20:56 -04:00
dnorberg 3c900634c9 Define server-authoritative symbol semantics (#29)
Build / build (push) Canceled after 0s
2026-09-30 07:17:14 -04:00
dnorberg 8cce41fd71 docs: audit Legacy age grammar and symbol page composition (#28)
Build / build (push) Canceled after 0s
2026-09-30 06:46:45 -04:00
44 changed files with 864 additions and 147 deletions

No files matched your search

+2 -1
View File
@@ -23,7 +23,8 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| `assets/rivencraft/textures/block/ink_mixer_top.png`, `ink_mixer_side.png`, `ink_mixer_bottom.png` | `assets/mystcraft/textures/blocks/inkmixer_top.png`, `inkmixer_side.png`, `inkmixer_bottom.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Rivencraft supplies its own block model | | `assets/rivencraft/textures/block/ink_mixer_top.png`, `ink_mixer_side.png`, `ink_mixer_bottom.png` | `assets/mystcraft/textures/blocks/inkmixer_top.png`, `inkmixer_side.png`, `inkmixer_bottom.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Rivencraft supplies its own block model |
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only | | `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample | | `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
| `assets/rivencraft/textures/item/page_symbol*.png` and `page_unresolved.png` | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Static 1.21.1 renditions of the legacy runtime-composed four-word symbol pages; `normal_time` uses the legacy Full Length poem | | `assets/rivencraft/textures/gui/symbolcomponents.png` | `assets/mystcraft/textures/symbolcomponents.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Byte-identical shared 512×512 component sheet for client-side page composition |
| Runtime-composed symbol pages (`SymbolPageTextures`) | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Replaces the six pre-generated symbol PNGs; layers four poem words on the shared page background and caches bounded client textures |
Mystcraft Legacy identifies XCompWiz as its project developer and includes the Mystcraft Legacy identifies XCompWiz as its project developer and includes the
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE` full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
+4 -4
View File
@@ -1,15 +1,15 @@
# GameTest suites # GameTest suites
The default dedicated-server run is a small smoke suite: 16 Rivencraft tests The default dedicated-server run is a small smoke suite: 17 Rivencraft tests
cover core linking, deterministic Age definition, writing persistence, legacy cover core linking, deterministic Age definition, writing persistence, legacy
data migration, workstation transactions, and forged/replayed menu actions. data migration, workstation transactions, and forged/replayed menu actions.
NeoForge currently adds two of its own tests, so the runner reports 18. NeoForge currently adds two of its own tests, so the runner reports 19.
```shell ```shell
./gradlew runGameTestServer ./gradlew runGameTestServer
``` ```
The remaining 46 Rivencraft tests are archived, not deleted. They cover The remaining 47 Rivencraft tests are archived, not deleted. They cover
completed milestone details such as every Age terrain profile, Notebook and completed milestone details such as every Age terrain profile, Notebook and
Desk surface interactions, refill edge cases, book assembly controls, and Desk surface interactions, refill edge cases, book assembly controls, and
workstation break/drop behavior. Run only the relevant archive alongside the workstation break/drop behavior. Run only the relevant archive alongside the
@@ -21,7 +21,7 @@ core tests when investigating an issue:
``` ```
Available suite names are `linking`, `writing`, `desk`, `binder`, `mixer`, and Available suite names are `linking`, `writing`, `desk`, `binder`, `mixer`, and
`recipe`. Run all 62 Rivencraft tests (64 including NeoForge's two) before a `recipe`. Run all 64 Rivencraft tests (66 including NeoForge's two) before a
release or after broad cross-system changes: release or after broad cross-system changes:
```shell ```shell
+58
View File
@@ -0,0 +1,58 @@
# Mystcraft Legacy Age grammar audit (milestone 5, issue #28)
Reference: [Mystcraft-Legacy commit `9bc8ddc`](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae), whose [build properties](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/build.properties) identify version 0.13.7.06. The user-provided local Minecraft 1.12.2 extraction (`mystcraft-1.12.2-0.13.7.06`) has the same version in `mcmod.info`; its `assets/mystcraft/textures/symbolcomponents.png` is byte-identical to the source checkout. That extraction contains compiled classes and assets, but no shipped `.cfg` or `.java` files. Source-linked behavioral claims below use the corresponding GitHub Java source. This is a behavior specification, not a code port. Any later adapted code needs its own copyright/provenance record under [PORTING.md](PORTING.md).
## What Legacy actually does
Legacy's [root rule](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/GrammarRules.java#L62) produces, in order: terrain, biome controller, weather, lighting, optional spawning, suns, moons, starfields, doodads, visuals, small/medium/large features, and effects. Each symbol declares the rule it can satisfy and any preceding modifier tokens it needs in [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java). The written page order therefore matters: modifiers appear before their consumer, while missing grammar branches can be generated. The [grammar tree](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/grammar/GrammarTree.java#L158-L222) connects authored terminals and inserts missing branches; [AgeData](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/agedata/AgeData.java#L230-L258) does this on first link with an Age-seeded RNG. Weighted rule *ranks* choose generated symbols; a `null` rank is not randomly generated, though such a rule can connect authored pages ([GrammarGenerator](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/grammar/GrammarGenerator.java#L28-L112)).
Legacy does **not** reject a book just because it omits terrain, biome, weather, or lighting. It fills the gaps. It also has separate instability accounting for dangling/excess symbols; this audit records those as diagnostics, not as milestone 5 generation effects ([AgeController](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java#L196-L247)).
## Symbol-to-Rivencraft mapping
| Legacy category and symbols | Existing Rivencraft path | Milestone 5 disposition |
| --- | --- | --- |
| `TerrainNormal`, `TerrainAmplified`, `TerrainFlat`, `TerrainVoid`, `TerrainNether`, `TerrainEnd`; terrain and sea block modifiers | `AgeProfile` selects noise/flat/void and registered noise settings; random profiles already persist per Age | Compile supported terrain shapes into profile/resources. Nether/End and material modifiers require explicit compatible data-pack resources; do not claim exact 1.12 terrain parity. |
| `BioConNative`, `Single`, `Tiled`, `Grid`, `Tiny`, `Small`, `Medium`, `Large`, `Huge`, plus biome pages | `AgeTraits.BiomeDistribution` has all nine modes; `AgeDimensionService` has sources for these modes | Map modes, consume authored biome IDs in order, then deterministically fill required minimums. Native uses the profile's modern biome preset, not the old `BiomeProvider`. |
| `LightingNormal`, `Dark`, `Bright` | `AgeTraits.Lighting`, dimension type construction | Compile the choice to a stable dimension type/light behavior. Exact Legacy 1.12 brightness-table rendering is not guaranteed by NeoForge's dimension-type fields. |
| `WeatherNorm`, `Fast`, `Slow`, `Off`, `On`, `Cloudy`, `Rain`, `Snow`, `Storm` | `AgeTraits.Weather` has matching names except Legacy `On` maps to `ALWAYS` | Compile weather semantics separately from biome selection. `Snow` must remain deferred until optional season integration; do not silently substitute another weather mode. |
| Suns, moons, starfields; angle, phase, period, color/gradient/sunset modifiers | One `celestialPeriod`, `celestialAngle`, `celestialPhase` on `AgeTraits` | Compile only the representable single-cycle subset first. Multiple celestial bodies, independent periods, custom stars, gradients and sunsets need a richer definition and renderer, not flattening into one value. |
| Sky/fog/cloud/grass/foliage/water colors, natural variants, horizon, rainbow | Existing sky/dimension visuals and preview | Require an explicit modern client/biome-color design; metadata-only symbol pages must not pretend to change rendering. |
| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile and vanilla data-pack generation | Compile only features for which registered 1.21.1 placed features/structures exist. Defer bespoke structures and polish to milestone 7. |
| `EnvAccel`, explosions, lightning, meteors, scorching; PvP and dummy suppression pages | No equivalent Age-definition effect | Defer environmental hazards/instability effects to milestone 6, PvP rule to a separately scoped server policy; retain authored IDs without fake behavior. |
The Legacy symbol list and page names come from [ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L129-L240) and [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L19-L137). Rivencraft's [catalog](../src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java) currently holds display metadata only; its built-in set is just standard terrain, native biomes, normal weather, normal lighting, normal time, and caves. [OrderedWriting](../src/main/java/net/dnorberg/rivencraft/writing/OrderedWriting.java) preserves page order, but no compiler yet connects it to [AgeTraits](../src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java) or [Age profiles](AGE_PROFILES.md).
## Symbol-page artwork: `symbolcomponents.png`
Legacy does not allocate one image tile to each complete Age symbol. The [default word image source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/word/DrawableWord.java#L24-L75) is `mystcraft:textures/symbolcomponents.png`, a 512×512 RGBA sheet. It has an 8×8 grid of 64×64 component cells. For zero-based component index `n`, source pixels begin at `x = (n % 8) * 64`, `y = (n / 8) * 64`. [GuiUtils.drawComponent](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/gui/GuiUtils.java#L131-L152) uses that mapping for GUI drawing; [PageBuilder.PageSprite.stitchWord](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/render/PageBuilder.java#L287-L331) uses it when composing an item sprite. Cell `0` is the missing-word/question-mark fallback. Each component may be tinted and its alpha layered with the other components of a word.
The complete lookup chain for a unique page is:
1. [WordData.init](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/word/WordData.java#L96-L166) registers a list of component indices for each named word (for example, `Nature` uses `5, 6, 8, 10, 11, 12, 15, 16, 17, 22`). [DrawableWord](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/word/DrawableWord.java#L26-L75) also permits per-component colors and an alternate image source. Unregistered words get a deterministic component list from their lowercase name hash ([DrawableWordManager](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/words/DrawableWordManager.java#L34-L56)).
2. [ModSymbols.registerSymbol](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L105-L116) assigns four poem words to each Age symbol. For example, `LightingNormal` is `Ethereal, Dynamic, Cycle, Balance` ([registration](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L184-L186)). The poem, not the symbol ID, selects the component layers.
3. [PageBuilder.PageSprite](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/render/PageBuilder.java#L232-L279) draws those words above, right, below, and left of the page center, respectively. Their target rectangles on its 160×160 working image are `(48,0)`, `(96,48)`, `(48,96)`, `(0,48)`, each 64×64 ([getPageTarget](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/render/PageBuilder.java#L360-L378)). It scales the composed image to a 128×128 item sprite. [GuiUtils.drawSymbol](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/gui/GuiUtils.java#L74-L96) renders the same four-word arrangement directly in book/workstation GUIs.
For Rivencraft, keep **symbol semantics** (`SymbolReference`/catalog ID) separate from **page artwork** (four ordered word names, their component indices/colors, and the shared sheet). Existing built-in page images are static renditions of this process ([asset provenance](ASSET_PROVENANCE.md)); future symbol pages can compose it at build time or in a client-only renderer. Unknown words and missing symbols need distinct fallbacks, and data-pack-only symbols must remain usable without silently borrowing another symbol's image. Do not copy the Legacy renderer wholesale: its texture-atlas and GUI APIs target 1.12.2.
## Verified numeric defaults and bounds
These values are from code, **not** a supposed shipped `.cfg`: the inspected Legacy repository contains no default configuration file with numeric min/max fields for age grammar. [MystConfig](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/config/MystConfig.java) supplies typed config helpers, while symbol enablement uses a Boolean `<symbol>.enabled` option ([SymbolManager](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/SymbolManager.java#L91-L107)). Do not label Rivencraft's present numeric range as a Legacy config range.
| Parameter | Legacy source value/rule | Implementation consequence |
| --- | --- | --- |
| Period pages | `Zero=0`, `Half=0.5`, `Full=1`, `Double=2` ([ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L194-L201)); repeated period modifiers average pairwise ([SymbolLength](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/modifiers/SymbolLength.java#L25-L37)) | Authored basic values have min 0 and max 2; no arbitrary continuous written range is established. A sun without a period uses `0.8 + 0.4 * nextDouble()`—`[0.8, 1.2)`—before conversion to ticks ([SymbolSunNormal](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolSunNormal.java#L47-L73)). Rivencraft's random nonzero `[0.2, 2.0)` is its own existing policy. |
| Angle pages | North/East/South/West = `0/90/180/270` degrees ([ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L187-L190)); repeated values use shortest-arc averaging ([ModifierUtils](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/symbol/ModifierUtils.java#L54-L73)) | Canonical angle is `[0, 360)`; consumers may seed a random angle if omitted. |
| Phase pages | Nadir/Rising/Zenith/Setting = `0/90/180/270` degrees ([ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L198-L201)); the sun divides by 360 ([SymbolSunNormal](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolSunNormal.java#L65-L73)) | Written phase is `[0, 360)` degrees, represented by Rivencraft as `[0, 1)` turns. |
| Biome count | Grammar requires one page for `Single`, `Tiled`, `Grid`; `Tiny`–`Huge` have two explicit biome slots plus a biome-list branch ([SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L91-L99)). `Single` fills to 1 ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerSingle.java#L22-L37)), `Tiled`/`Grid` to 2 ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerTiled.java#L25-L38)), and `Tiny`–`Huge` to 3 ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerLarge.java#L61-L84)) | These are **minimums**, not maximums. `Native` uses vanilla's provider and has no authored biome-list requirement ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerNative.java#L34-L44)). Preserve additional authored biomes where the chosen controller can accept them. |
| Weather normal | Rain duration/base `12000/12000`, cooldown/base `168000/12000`; thunder duration/base `12000/3600`, cooldown/base `168000/12000` ticks ([SymbolWeatherNormal](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolWeatherNormal.java#L25-L36)) | These are source constants, not configurable min/max bounds. Other weather controllers need individual review before parity claims. |
## Compilation and fallback contract for the next issues
1. A Link-Panel-only book, a legacy page-free book, and any book with no effective supported Age symbols retain the current seed-derived random Age path. Preserve the saved writing untouched.
2. A supported partial sequence is **not** invalid: complete missing required branches deterministically from the Age seed, then compile the result. Authored symbols take precedence over generated fillers. This follows Legacy's grammar tree, rather than requiring a full hand-written list.
3. Unknown/unresolved IDs, unsupported-but-known effects, misplaced modifiers, duplicates, and conflicts must be reported distinctly. Do not silently apply a different effect or discard an authored page. A first compiler may use the existing random Age path for combinations it cannot represent, with an explicit diagnostic; it must never claim such a book was successfully compiled.
4. Grammar diagnostics are not instability. Milestone 5 decides recognition, ordering, completion, and whether a supported combination can compile. Milestone 6 may use dangling/excess/conflict diagnostics for instability effects. Milestone 7 owns bespoke structures and polish.
5. Freeze the completed, effective generation definition with the Age identity before creating chunks. Keep page order and original IDs for display/migration, and keep seeded completion deterministic across reloads. New symbol behavior belongs in versioned server data, not the schema-1 display catalog.
The mapping is intentionally narrower than all Legacy symbols. The exact rule and compiler schema are follow-up issues #29–#31; terrain, biome, environment, and feature implementations are #32–#35. No generation behavior or release version changes in this audit.
+2 -1
View File
@@ -39,7 +39,8 @@ acceptance issue and all required implementation issues are complete.
and [writing-system data and migration](WRITING_DATA.md). and [writing-system data and migration](WRITING_DATA.md).
5. **Age grammar** — translate symbol combinations into modern biome, noise, 5. **Age grammar** — translate symbol combinations into modern biome, noise,
dimension-type, and world-generation configuration. Optional integrations dimension-type, and world-generation configuration. Optional integrations
may add composable biome regions and seasonal behavior. may add composable biome regions and seasonal behavior. See the
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md).
6. **Instability** — deterministic validation and server-safe effects. 6. **Instability** — deterministic validation and server-safe effects.
7. **Structures and polish** — libraries, crystals, portals, recipes, models, 7. **Structures and polish** — libraries, crystals, portals, recipes, models,
sounds, advancements, compatibility, and migration tests. sounds, advancements, compatibility, and migration tests.
+44
View File
@@ -0,0 +1,44 @@
# Server-authoritative symbol semantics (schema v2)
Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalog. Put definitions at `data/<namespace>/rivencraft/symbols/<path>.json`; the file ID becomes the stable symbol ID. For example, `data/example/rivencraft/symbols/terrain/highlands.json` defines `example:terrain/highlands`:
```json
{
"schema_version": 2,
"name": "symbol.example.terrain.highlands",
"description": "symbol.example.terrain.highlands.description",
"category": "example:terrain",
"semantics": {
"role": "terrain",
"value": "example:highlands"
},
"poem": "Terrain Form Tradition Flow"
}
```
`name` and `description` are translation keys. `category` is for display and sorting; it does not determine grammar. `semantics.role` and its typed `value` are server-owned. The terrain value above names an Age runtime profile that a later compiler must resolve and validate. A syntactically valid resource ID alone does not guarantee the profile or feature exists.
`poem` is optional, client-safe artwork metadata: exactly four alphabetic words, ordered top, right, bottom, left. The client layers their glyphs from the shared Legacy component sheet at runtime. GUI thumbnails use the full transparent artwork over their page backgrounds; item icons use the same artwork reduced from 16 to 13 pixels across, over the page texture. Unregistered words use a stable generated component combination; when a symbol has no poem, the six original built-in IDs retain their Legacy poems and other IDs produce four stable fallback words. This affects appearance only, never Age generation. Generated images are cached up to 256 entries and discarded on resource reload.
The Writing Desk's 88 collection tabs use Legacy's number components, chiefly the bottom row of `symbolcomponents.png`, rather than word sigils. Legacy's `ModWords.constructNumber` combines four unit marks (1–4) with four five-step marks (5–20) to form 1–24 and uses a separate zero mark. Rivencraft extends these marks positionally in base 25 for tabs 25–88 (for example, 25 is 1 followed by 0). Hovering still reveals the decimal number for accessibility. The former arbitrary-word glyph generator remains useful for rendering text: on page-free Descriptive Books, the generated Age description now appears as a sequence of layered word glyphs instead of Minecraft's enchantment font. The Age name stays readable.
| Role | Value type and allowed values |
| --- | --- |
| `terrain` | Canonical namespaced Age profile ID. |
| `biome_controller` | `native`, `single`, `tiled`, `grid`, `tiny`, `small`, `medium`, `large`, or `huge`. |
| `biome` | Canonical namespaced biome ID. |
| `lighting` | `normal`, `dark`, or `bright`. |
| `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. |
| `celestial` | `normal_time` (the first representable single-cycle behavior). |
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. |
| `angle_modifier` | JSON number in `[0, 360)` degrees. |
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler will convert to turns. |
| `feature` | Canonical namespaced feature-effect ID; future generation mapping must recognize it. |
These are schema bounds, not claims that Legacy shipped numeric config ranges. See the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source values and biome-count rules. Roles outside this table—including multiple suns, gradients, special structures, and hazards—are intentionally unsupported by schema v2. Their meaning requires a later versioned extension, not an unvalidated free-form field.
The server rejects a v2 definition with an unknown top-level or semantics field, missing/incorrectly typed field, unknown role or choice, malformed/non-canonical resource ID, or out-of-range/non-finite number. An invalid definition is logged with its file ID and excluded from the active catalog. Saved pages and books still retain that ID, allowing the player to recover them if the data pack is corrected. A reload atomically replaces the catalog rather than mutating saved writing.
Schema v1 definitions remain readable as **display-only** symbols; existing packs need not be rewritten merely to show or copy pages. A v1 definition cannot add a `semantics` field. To make one eligible for future Age compilation, publish a v2 definition with the **same file path and ID**, adding a valid role and value. Do not rename the page ID or reorder a book's `OrderedWriting`. The client receives only `SymbolDisplayMetadata` (ID, translation keys, category, resolved flag); semantic definitions are not serialized to it. This slice defines and validates meaning but does **not yet apply** written symbols to Age generation; issues #30 and #31 implement parsing and compilation.
The six built-in definitions demonstrate terrain, biome-controller, lighting, weather, celestial, and feature roles. A third-party pack may add its own definitions under its namespace. Use an explicit semantic role even when the display category has the same name; the two fields serve different purposes.
+10 -4
View File
@@ -1,9 +1,11 @@
# Writing and symbol data # Writing and symbol data
Milestone 4 stores what a player wrote without deciding what that writing does Milestone 4 stores what a player wrote without deciding what that writing does
to an Age. Milestone 5 owns grammar validation, symbol interaction, instability, to an Age. Milestone 5 owns grammar validation and symbol interaction,
and translation into biome, noise, dimension-type, and other world-generation and translation into biome, noise, dimension-type, and other world-generation
configuration. configuration. Milestone 6 owns instability effects. See the
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source-backed
Milestone 5 contract.
## Symbol catalog ## Symbol catalog
@@ -25,13 +27,17 @@ Schema version 1 contains display metadata only:
`name` and `description` are translation keys, not arbitrary formatted text or `name` and `description` are translation keys, not arbitrary formatted text or
executable behavior. `category` is a namespaced organizational identifier. executable behavior. `category` is a namespaced organizational identifier.
Milestone 5 adds [server-authoritative schema v2 semantics](SYMBOL_SEMANTICS.md)
with a typed role and bounded value. Existing v1 definitions remain readable
but display-only; updating an ID to v2 never rewrites saved pages or books.
Invalid definitions are logged and excluded by the server. Menus and future Invalid definitions are logged and excluded by the server. Menus and future
page synchronization must obtain resolved display metadata from the server; page synchronization must obtain resolved display metadata from the server;
clients never make a symbol valid by naming it. clients never make a symbol valid by naming it.
Rivencraft ships a small catalog covering terrain, biomes, lighting, weather, Rivencraft ships a small catalog covering terrain, biomes, lighting, weather,
celestial behavior, and features. It exists to exercise the complete writing celestial behavior, and features. Its v2 semantics prepare for the full
workflow before Milestone 5 implements the full legacy grammar. Legacy-inspired grammar; writing still uses random Age fallback until the
parser and compiler are implemented.
## Persistent writing ## Persistent writing
+1 -1
View File
@@ -36,7 +36,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.4.0.2 mod_version=0.4.1.5
immersive_portals_version=6.0.7 immersive_portals_version=6.0.7
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources. # This should match the base package used for the mod sources.
@@ -13,6 +13,10 @@ import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.client.event.EntityRenderersEvent; import net.neoforged.neoforge.client.event.EntityRenderersEvent;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent; import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.neoforged.neoforge.client.event.RegisterClientReloadListenersEvent;
import net.neoforged.neoforge.client.extensions.common.IClientItemExtensions;
import net.neoforged.neoforge.client.extensions.common.RegisterClientExtensionsEvent;
import net.minecraft.client.renderer.BlockEntityWithoutLevelRenderer;
import net.dnorberg.rivencraft.client.screen.NotebookScreen; import net.dnorberg.rivencraft.client.screen.NotebookScreen;
import net.dnorberg.rivencraft.client.screen.WritingDeskScreen; import net.dnorberg.rivencraft.client.screen.WritingDeskScreen;
import net.dnorberg.rivencraft.client.screen.BookBinderScreen; import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
@@ -38,6 +42,24 @@ public final class ClientEvents {
event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new); event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new);
} }
@SubscribeEvent
public static void registerPageItemRenderer(RegisterClientExtensionsEvent event) {
event.registerItem(new IClientItemExtensions() {
private final SymbolPageItemRenderer renderer = new SymbolPageItemRenderer();
@Override
public BlockEntityWithoutLevelRenderer getCustomRenderer() {
return renderer;
}
}, ModItems.PAGE.get());
}
@SubscribeEvent
public static void registerPageTextureReload(RegisterClientReloadListenersEvent event) {
event.registerReloadListener((net.minecraft.server.packs.resources.ResourceManagerReloadListener)
manager -> SymbolPageTextures.clear());
}
@SubscribeEvent @SubscribeEvent
public static void clientSetup(FMLClientSetupEvent event) { public static void clientSetup(FMLClientSetupEvent event) {
event.enqueueWork(() -> ItemProperties.register( event.enqueueWork(() -> ItemProperties.register(
@@ -0,0 +1,47 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.BlockEntityWithoutLevelRenderer;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
/** Flat page model whose symbol artwork is resolved from the stack at render time. */
public final class SymbolPageItemRenderer extends BlockEntityWithoutLevelRenderer {
public SymbolPageItemRenderer() {
super(Minecraft.getInstance().getBlockEntityRenderDispatcher(), Minecraft.getInstance().getEntityModels());
}
@Override
public void renderByItem(ItemStack stack, ItemDisplayContext context, PoseStack pose,
MultiBufferSource buffers, int light, int overlay) {
ResourceLocation texture = switch (PageItem.content(stack).kind()) {
case BLANK -> texture("page");
case LINK_PANEL -> texture("page_link_panel");
case UNRESOLVED_SYMBOL -> texture("page_unresolved");
case SYMBOL -> SymbolPageTextures.forItem(stack);
};
VertexConsumer vertices = buffers.getBuffer(RenderType.entityCutoutNoCull(texture));
var matrix = pose.last().pose();
vertex(vertices, matrix, 0, 0, 0, 1, light, overlay);
vertex(vertices, matrix, 1, 0, 1, 1, light, overlay);
vertex(vertices, matrix, 1, 1, 1, 0, light, overlay);
vertex(vertices, matrix, 0, 1, 0, 0, light, overlay);
}
private static void vertex(VertexConsumer vertices, org.joml.Matrix4f matrix,
float x, float y, float u, float v, int light, int overlay) {
vertices.addVertex(matrix, x, y, 0.5F).setColor(255, 255, 255, 255)
.setUv(u, v).setOverlay(overlay).setLight(light).setNormal(0, 0, 1);
}
private static ResourceLocation texture(String name) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/item/" + name + ".png");
}
}
@@ -0,0 +1,240 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.NativeImage;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.SymbolPoems;
import net.dnorberg.rivencraft.writing.SymbolNumerals;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.world.item.ItemStack;
/** Client-only, bounded cache of Legacy-style page compositions; no per-symbol PNGs. */
public final class SymbolPageTextures {
private static final int SIZE = 128;
private static final int GLYPH = 51;
// A 16 px item icon displays a 13 px symbol: three pixels narrower overall.
private static final int ITEM_SYMBOL_SIZE = 13;
private static final int ICON_SIZE = 16;
private static final int CACHE_LIMIT = 256;
private static final ResourceLocation BASE = texture("item/page");
private static final ResourceLocation COMPONENTS = texture("gui/symbolcomponents");
private static final ResourceLocation FALLBACK = texture("item/page_symbol");
private static final Map<String, ResourceLocation> CACHE = new LinkedHashMap<>(16, .75F, true);
private static final Map<String, int[]> WORD_CACHE = new LinkedHashMap<>(16, .75F, true);
private static NativeImage base;
private static NativeImage components;
private static int nextId;
// WordData from Mystcraft Legacy 0.13.7.06. Other words get a stable generated glyph.
private static final Map<String, int[]> WORDS = Map.ofEntries(
Map.entry("Terrain", new int[] {6,10,12,13,16,19,24,25,27}),
Map.entry("Form", new int[] {4,6,8,9,10,11,13,14,16,23}),
Map.entry("Tradition", new int[] {4,6,7,8,9,14,17,18,20}),
Map.entry("Flow", new int[] {4,7,12,13,17,18}),
Map.entry("Constraint", new int[] {5,6,9,11,13,20,22,23}),
Map.entry("Nature", new int[] {5,6,8,10,11,12,15,16,17,22}),
Map.entry("Sustain", new int[] {6,16,19,20,21,23}),
Map.entry("Ethereal", new int[] {4,6,8,13,14,16,18,21,23}),
Map.entry("Dynamic", new int[] {4,5,6,12,13,17,18,22,23}),
Map.entry("Cycle", new int[] {4,5,7,16,17,18}),
Map.entry("Balance", new int[] {4,6,9,10,16,19}),
Map.entry("Transform", new int[] {4,5,6,8,11,14,18,20,21}),
Map.entry("Time", new int[] {4,5,7,12,13,19,20,21,22,23}),
Map.entry("System", new int[] {10,12,14,15,16,20,21,22}),
Map.entry("Void", new int[] {4,5,6,16,17,18,23})
);
private SymbolPageTextures() {}
/** Transparent full-size artwork for GUI thumbnails drawn over their own page background. */
public static ResourceLocation forPage(ItemStack stack) {
return forPage(stack, false);
}
/** Full page texture with artwork reduced by three pixels at 16 px item-icon scale. */
public static ResourceLocation forItem(ItemStack stack) {
return forPage(stack, true);
}
private static ResourceLocation forPage(ItemStack stack, boolean item) {
PageContent content = PageItem.content(stack);
if (content.kind() == PageContent.Kind.UNRESOLVED_SYMBOL) return texture("item/page_unresolved");
if (content.kind() != PageContent.Kind.SYMBOL) return FALLBACK;
SymbolDisplayMetadata metadata = stack.get(ModDataComponents.PAGE_SYMBOL_DISPLAY.get());
ResourceLocation id = content.symbol().orElseThrow().id();
return forSymbol(id, metadata == null ? "" : metadata.poem(), item);
}
private static ResourceLocation forSymbol(ResourceLocation id, String poem, boolean item) {
if (poem.length() > 96 || !poem.matches("[A-Za-z]+(?: [A-Za-z]+){3}")) poem = "";
if (poem.isEmpty()) poem = SymbolPoems.builtIn(id);
String key = id + "|" + poem + (item ? "|item" : "|thumbnail");
ResourceLocation cached = CACHE.get(key);
if (cached != null) return cached;
try {
loadSources(Minecraft.getInstance().getResourceManager());
NativeImage image = compose(id, poem, item);
ResourceLocation location = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "generated/page_" + nextId++);
Minecraft.getInstance().getTextureManager().register(location, new DynamicTexture(image));
CACHE.put(key, location);
if (CACHE.size() > CACHE_LIMIT) {
String eldest = CACHE.keySet().iterator().next();
Minecraft.getInstance().getTextureManager().release(CACHE.remove(eldest));
}
return location;
} catch (IOException exception) {
Rivencraft.LOGGER.error("Could not compose symbol page {}", id, exception);
return FALLBACK;
}
}
public static void clear() {
var textures = Minecraft.getInstance().getTextureManager();
CACHE.values().forEach(textures::release);
CACHE.clear();
WORD_CACHE.clear();
if (base != null) base.close();
if (components != null) components.close();
base = null;
components = null;
}
private static void loadSources(ResourceManager manager) throws IOException {
if (base != null && components != null) return;
try (InputStream page = manager.getResourceOrThrow(BASE).open();
InputStream sheet = manager.getResourceOrThrow(COMPONENTS).open()) {
base = NativeImage.read(page);
components = NativeImage.read(sheet);
}
}
private static NativeImage compose(ResourceLocation id, String poem, boolean item) {
// GUI variants have no parchment layer, so their untouched pixels must be transparent.
NativeImage image = new NativeImage(SIZE, SIZE, true);
if (item) {
for (int y = 0; y < SIZE; y++) for (int x = 0; x < SIZE; x++) {
image.setPixelRGBA(x, y, base.getPixelRGBA(x * base.getWidth() / SIZE,
y * base.getHeight() / SIZE));
}
}
String[] words = poem.isBlank() ? generatedWords(id) : poem.split(" ");
int[] offsetsX = {38, 77, 38, 0};
int[] offsetsY = {0, 38, 77, 38};
int glyphSize = item ? GLYPH * ITEM_SYMBOL_SIZE / ICON_SIZE : GLYPH;
for (int word = 0; word < 4; word++) {
int originX = item ? scaleForItem(offsetsX[word]) : offsetsX[word];
int originY = item ? scaleForItem(offsetsY[word]) : offsetsY[word];
for (int component : glyphs(words[word])) {
for (int y = 0; y < glyphSize; y++) for (int x = 0; x < glyphSize; x++) {
int pixel = components.getPixelRGBA((component % 8) * 64 + x * 64 / glyphSize,
(component / 8) * 64 + y * 64 / glyphSize);
int alpha = pixel >>> 24;
if (alpha != 0) image.blendPixel(originX + x, originY + y,
(alpha << 24) | 0x001A212B);
}
}
}
return image;
}
private static int scaleForItem(int coordinate) {
return SIZE / 2 + Math.round((coordinate - SIZE / 2F) * ITEM_SYMBOL_SIZE / ICON_SIZE);
}
private static String[] generatedWords(ResourceLocation id) {
String stem = id.toString();
return new String[] {stem + 'a', stem + 'b', stem + 'c', stem + 'd'};
}
private static int[] glyphs(String word) {
int[] known = WORDS.get(word);
if (known != null) return known;
String key = word.toLowerCase(Locale.ROOT);
int[] cached = WORD_CACHE.get(key);
if (cached != null) return cached;
Random random = new Random(key.hashCode());
int[] components = new int[random.nextInt(10) + 3];
for (int index = 0; index < components.length; index++) components[index] = random.nextInt(20) + 4;
WORD_CACHE.put(key, components);
if (WORD_CACHE.size() > CACHE_LIMIT) WORD_CACHE.remove(WORD_CACHE.keySet().iterator().next());
return components;
}
/** Draw a word as a layered Legacy glyph, or a number with Legacy's number components. */
public static void drawWordGlyph(GuiGraphics graphics, String word, int x, int y, int size) {
if (word.matches("[0-9]+")) {
try {
drawNumeral(graphics, Integer.parseInt(word), x, y, size);
return;
} catch (NumberFormatException ignored) {
// Oversized numerals still receive a stable word glyph.
}
}
graphics.setColor(0.13F, 0.10F, 0.08F, 1F);
for (int component : glyphs(word)) drawComponent(graphics, component, x, y, size);
graphics.setColor(1F, 1F, 1F, 1F);
}
/** Legacy's single-number marks; numbers above 24 use positional base 25. */
public static void drawNumeral(GuiGraphics graphics, int number, int x, int y, int size) {
int[] digits = SymbolNumerals.digitsFor(number);
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
graphics.setColor(0.13F, 0.10F, 0.08F, 1F);
for (int digit : digits) {
for (int component : SymbolNumerals.componentsForDigit(digit))
drawComponent(graphics, component, x, y + (size - digitSize) / 2, digitSize);
x += digitSize + 1;
}
graphics.setColor(1F, 1F, 1F, 1F);
}
/** Render translated prose as a sequence of word glyphs within a fixed-width book page. */
public static int drawGlyphText(GuiGraphics graphics, String text, int x, int y, int maxWidth) {
int cursor = x;
boolean drew = false;
for (String word : text.split("[^\\p{L}\\p{N}]+")) {
if (word.isEmpty()) continue;
int width = word.matches("[0-9]+") ? numeralWidth(word, 12) : 12;
if (cursor != x && cursor + width > x + maxWidth) {
cursor = x;
y += 13;
}
drawWordGlyph(graphics, word, cursor, y, 12);
cursor += width + 3;
drew = true;
}
return y + (drew ? 13 : 0);
}
private static int numeralWidth(String word, int size) {
try {
int count = SymbolNumerals.digitsFor(Integer.parseInt(word)).length;
return count == 1 ? size : count * (size * 3 / 4 + 1) - 1;
} catch (NumberFormatException ignored) {
return size;
}
}
private static void drawComponent(GuiGraphics graphics, int component, int x, int y, int size) {
graphics.blit(COMPONENTS, x, y, size, size,
(component % 8) * 64, (component / 8) * 64, 64, 64, 512, 512);
}
private static ResourceLocation texture(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
}
}
@@ -146,7 +146,7 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
case BLANK -> { } case BLANK -> { }
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000); case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000);
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page), case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page),
x + 1, y + 5, 24, 24, 16, 16, 96, 96, 128, 128); x + 1, y + 5, 24, 24, 0, 0, 128, 128, 128, 128);
} }
} }
@@ -5,6 +5,7 @@ import java.util.Locale;
import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeTraits; import net.dnorberg.rivencraft.book.AgeTraits;
import net.dnorberg.rivencraft.client.ClientBookExploreOverlay; import net.dnorberg.rivencraft.client.ClientBookExploreOverlay;
import net.dnorberg.rivencraft.client.SymbolPageTextures;
import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload; import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload;
import net.dnorberg.rivencraft.network.BindDescriptiveBookPayload; import net.dnorberg.rivencraft.network.BindDescriptiveBookPayload;
import net.dnorberg.rivencraft.network.ModNetworking; import net.dnorberg.rivencraft.network.ModNetworking;
@@ -37,7 +38,6 @@ import org.jetbrains.annotations.Nullable;
public final class DescriptiveBookScreen extends Screen implements ExplorableBookScreen { public final class DescriptiveBookScreen extends Screen implements ExplorableBookScreen {
private static final int BOOK_TEXT_COLOR = 0xFF3B281B; private static final int BOOK_TEXT_COLOR = 0xFF3B281B;
private static final int UNRESOLVED_TEXT_COLOR = 0xFF9B302C; private static final int UNRESOLVED_TEXT_COLOR = 0xFF9B302C;
private static final ResourceLocation ENCHANTMENT_FONT = ResourceLocation.withDefaultNamespace("alt");
private static final int BOOK_WIDTH = 327; private static final int BOOK_WIDTH = 327;
private static final int BOOK_HEIGHT = 199; private static final int BOOK_HEIGHT = 199;
private static final int LINK_PANEL_X = 173; private static final int LINK_PANEL_X = 173;
@@ -211,7 +211,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
Component profileName = generationProfile.getNamespace().equals("rivencraft") Component profileName = generationProfile.getNamespace().equals("rivencraft")
? Component.translatable("generator.rivencraft." + generationProfile.getPath()) ? Component.translatable("generator.rivencraft." + generationProfile.getPath())
: Component.literal(generationProfile.toString()); : Component.literal(generationProfile.toString());
detailsY = drawWrappedWithoutShadow(graphics, detailsY = drawDescription(graphics,
Component.translatable("screen.rivencraft.age.generator", profileName), Component.translatable("screen.rivencraft.age.generator", profileName),
x, detailsY, 110) + 2; x, detailsY, 110) + 2;
} }
@@ -415,10 +415,16 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
} }
private int drawTrait(GuiGraphics graphics, String key, Component value, int x, int y) { private int drawTrait(GuiGraphics graphics, String key, Component value, int x, int y) {
return drawWrappedWithoutShadow(graphics, return drawDescription(graphics,
Component.translatable("screen.rivencraft.age.trait." + key, value), x, y, 110) + 2; Component.translatable("screen.rivencraft.age.trait." + key, value), x, y, 110) + 2;
} }
private int drawDescription(GuiGraphics graphics, Component text, int x, int y, int maxWidth) {
return pageFree && age != null
? SymbolPageTextures.drawGlyphText(graphics, text.getString(), x, y, maxWidth)
: drawWrappedWithoutShadow(graphics, text, x, y, maxWidth);
}
private static Component traitName(String group, Enum<?> value) { private static Component traitName(String group, Enum<?> value) {
return Component.translatable("trait.rivencraft." + group + "." return Component.translatable("trait.rivencraft." + group + "."
+ value.name().toLowerCase(Locale.ROOT)); + value.name().toLowerCase(Locale.ROOT));
@@ -429,9 +435,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
} }
private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth, int color) { private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth, int color) {
Component displayed = pageFree && age != null && pageIndex == 0 for (FormattedCharSequence line : font.split(text, maxWidth)) {
? text.copy().withStyle(style -> style.withFont(ENCHANTMENT_FONT)) : text;
for (FormattedCharSequence line : font.split(displayed, maxWidth)) {
graphics.drawString(font, line, x, y, color, false); graphics.drawString(font, line, x, y, color, false);
y += font.lineHeight; y += font.lineHeight;
} }
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.client.screen;
import java.util.Locale; import java.util.Locale;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.client.SymbolPageTextures;
import net.dnorberg.rivencraft.menu.NotebookMenu; import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
@@ -26,14 +27,6 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
private static final int PAGE_STEP_Y = 41; private static final int PAGE_STEP_Y = 41;
private static final int PAGE_COLUMNS = 5; private static final int PAGE_COLUMNS = 5;
private static final ResourceLocation PAGE_ATLAS = texture("gui/notebook_pages"); private static final ResourceLocation PAGE_ATLAS = texture("gui/notebook_pages");
private static final ResourceLocation GENERIC_SYMBOL = texture("item/page_symbol");
private static final ResourceLocation UNRESOLVED_SYMBOL = texture("item/page_unresolved");
private static final ResourceLocation STANDARD_TERRAIN = texture("item/page_symbol_terrain_standard");
private static final ResourceLocation NATIVE_BIOMES = texture("item/page_symbol_biome_native");
private static final ResourceLocation NORMAL_LIGHTING = texture("item/page_symbol_lighting_normal");
private static final ResourceLocation NORMAL_WEATHER = texture("item/page_symbol_weather_normal");
private static final ResourceLocation NORMAL_TIME = texture("item/page_symbol_celestial_normal_time");
private static final ResourceLocation CAVES = texture("item/page_symbol_feature_caves");
private EditBox textInput; private EditBox textInput;
private boolean draggingScrollbar; private boolean draggingScrollbar;
private boolean surfaceClickPendingRelease; private boolean surfaceClickPendingRelease;
@@ -262,7 +255,7 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
case BLANK -> { } case BLANK -> { }
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000); case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000);
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(symbolTexture(page), x + 1, y + 6, case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(symbolTexture(page), x + 1, y + 6,
28, 28, 16, 16, 96, 96, 128, 128); 28, 28, 0, 0, 128, 128, 128, 128);
} }
} }
if (dimmed) { if (dimmed) {
@@ -274,23 +267,7 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
} }
static ResourceLocation symbolTexture(ItemStack page) { static ResourceLocation symbolTexture(ItemStack page) {
var content = PageItem.content(page); return SymbolPageTextures.forPage(page);
if (content.kind() == net.dnorberg.rivencraft.writing.PageContent.Kind.UNRESOLVED_SYMBOL) {
return UNRESOLVED_SYMBOL;
}
return symbolTexture(content.symbol().orElseThrow().id());
}
static ResourceLocation symbolTexture(ResourceLocation id) {
return switch (id.toString()) {
case "rivencraft:terrain/standard" -> STANDARD_TERRAIN;
case "rivencraft:biome/native" -> NATIVE_BIOMES;
case "rivencraft:lighting/normal" -> NORMAL_LIGHTING;
case "rivencraft:weather/normal" -> NORMAL_WEATHER;
case "rivencraft:celestial/normal_time" -> NORMAL_TIME;
case "rivencraft:feature/caves" -> CAVES;
default -> GENERIC_SYMBOL;
};
} }
private static ResourceLocation texture(String path) { private static ResourceLocation texture(String path) {
@@ -5,6 +5,7 @@ import java.util.Locale;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.client.SymbolPageTextures;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.WritingDeskMenu; import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
@@ -148,7 +149,8 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
if (tab == menu.activeCollection() && !allMode) graphics.setColor(0.5F, 0.5F, 1F, 1F); if (tab == menu.activeCollection() && !allMode) graphics.setColor(0.5F, 0.5F, 1F, 1F);
graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37); graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37);
graphics.setColor(1F, 1F, 1F, 1F); graphics.setColor(1F, 1F, 1F, 1F);
graphics.drawString(font, Integer.toString(tab + 1), x + 8, tabY + 8, 0xFF181818, false); // Encode the visible one-based tab number with Legacy's numeric word glyph.
SymbolPageTextures.drawNumeral(graphics, tab + 1, x + 8, tabY + 3, 19);
ItemStack collection = menu.getSlot(tab).getItem(); ItemStack collection = menu.getSlot(tab).getItem();
if (!collection.isEmpty()) { if (!collection.isEmpty()) {
String name = collection.getHoverName().getString(); String name = collection.getHoverName().getString();
@@ -220,6 +222,11 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.previous_tabs"), mouseX, mouseY); graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.previous_tabs"), mouseX, mouseY);
else if (localY >= 177 && localY < 186) else if (localY >= 177 && localY < 186)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.next_tabs"), mouseX, mouseY); graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.next_tabs"), mouseX, mouseY);
else if (mouseX < leftPos + 34 && localY >= 29 && localY < 177) {
int tab = menu.firstVisibleTab() + (localY - 29) / 37;
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.tab_symbol",
tab + 1), mouseX, mouseY);
}
} }
} }
@@ -270,7 +277,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
case BLANK -> { } case BLANK -> { }
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000); case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000);
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page), x + 1, y + 6, case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page), x + 1, y + 6,
28, 28, 16, 16, 96, 96, 128, 128); 28, 28, 0, 0, 128, 128, 128, 128);
} }
} }
@@ -42,6 +42,11 @@ public final class CoreGameTests {
WritingDataGameTests.pageItemPreservesContentAndRejectsForgedUnknownSymbols(helper); WritingDataGameTests.pageItemPreservesContentAndRejectsForgedUnknownSymbols(helper);
} }
@GameTest(template = "empty")
public static void semanticSymbolSchema(GameTestHelper helper) {
WritingDataGameTests.semanticSchemaIsServerOnlyBoundedAndV1Compatible(helper);
}
@GameTest(template = "empty") @GameTest(template = "empty")
public static void notebookPersistence(GameTestHelper helper) { public static void notebookPersistence(GameTestHelper helper) {
WritingDataGameTests.notebookContentsPreserveOrderGapsNamesAndUnknownPages(helper); WritingDataGameTests.notebookContentsPreserveOrderGapsNamesAndUnknownPages(helper);
@@ -1,13 +1,19 @@
package net.dnorberg.rivencraft.gametest; package net.dnorberg.rivencraft.gametest;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps; import com.mojang.serialization.JsonOps;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog; import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDefinition;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.dnorberg.rivencraft.writing.SymbolPoems;
import net.dnorberg.rivencraft.writing.SymbolNumerals;
import net.dnorberg.rivencraft.writing.SymbolReference; import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry; import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.writing.NotebookContents; import net.dnorberg.rivencraft.writing.NotebookContents;
@@ -100,6 +106,80 @@ public final class WritingDataGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void semanticSchemaIsServerOnlyBoundedAndV1Compatible(GameTestHelper helper) {
var builtIn = SymbolCatalog.find(reference("rivencraft:terrain/standard")).orElseThrow();
helper.assertValueEqual(builtIn.schemaVersion(), 2, "Built-in symbol must use semantic schema v2");
helper.assertValueEqual(builtIn.semantics().orElseThrow().role(), SymbolSemantics.Role.TERRAIN,
"Built-in terrain symbol must declare its grammar role");
helper.assertValueEqual(builtIn.poem(), "Terrain Form Tradition Flow",
"Built-in page artwork must carry its four-word Legacy poem");
helper.assertValueEqual(SymbolPoems.builtIn(reference("rivencraft:terrain/standard").id()), builtIn.poem(),
"Pre-artwork saved starter pages must retain their original poem");
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(0), new int[] {1}),
"Zero must use Legacy's dedicated number mark");
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(1), new int[] {56}),
"One must use Legacy's first bottom-row mark");
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(24), new int[] {63, 59}),
"Twenty-four must combine Legacy's five and unit marks");
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(25), new int[] {1, 0}),
"Twenty-five must start a second base-25 place");
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(88), new int[] {3, 13}),
"Last writing-desk tab must retain its numeric value");
String display = "\"name\":\"symbol.example.test\","
+ "\"description\":\"symbol.example.test.description\","
+ "\"category\":\"example:terrain\"";
SymbolDefinition legacy = SymbolDefinition.parse(JsonParser.parseString(
"{\"schema_version\":1," + display + "}"));
helper.assertTrue(legacy.semantics().isEmpty(), "Existing v1 definitions must remain display-only");
SymbolDefinition oldCategory = SymbolDefinition.parse(JsonParser.parseString(
"{\"schema_version\":1,\"name\":\"symbol.example.test\","
+ "\"description\":\"symbol.example.test.description\","
+ "\"category\":\"terrain\"}"));
helper.assertValueEqual(oldCategory.category().toString(), "minecraft:terrain",
"A v1 category without a namespace must retain the old ResourceLocation behavior");
helper.assertValueEqual(legacy.display(reference("example:test").id()).id(),
reference("example:test").id(), "Display metadata must preserve the symbol ID");
SymbolDefinition period = SymbolDefinition.parse(JsonParser.parseString(
"{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"period_modifier\",\"value\":0}}"));
helper.assertValueEqual(period.semantics().orElseThrow().role(), SymbolSemantics.Role.PERIOD_MODIFIER,
"Zero-length modifier must be accepted");
rejectDefinition(helper, "{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"unknown\",\"value\":\"normal\"}}", "role");
rejectDefinition(helper, "{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"weather\",\"value\":\"blizzard\"}}", "value");
rejectDefinition(helper, "{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"period_modifier\",\"value\":2.01}}", "value");
rejectDefinition(helper, "{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"terrain\",\"value\":\"minecraft:overworld\",\"extra\":1}}", "extra");
rejectDefinition(helper, "{\"schema_version\":1," + display
+ ",\"semantics\":{\"role\":\"weather\",\"value\":\"normal\"}}", "v1");
var clientJson = builtIn.display(reference("rivencraft:terrain/standard").id());
var encoded = net.dnorberg.rivencraft.writing.SymbolDisplayMetadata.CODEC
.encodeStart(JsonOps.INSTANCE, clientJson).result().orElseThrow();
helper.assertFalse(encoded.getAsJsonObject().has("semantics"),
"Client display payload must never contain generation semantics");
helper.assertValueEqual(encoded.getAsJsonObject().get("poem").getAsString(), builtIn.poem(),
"Client display payload must carry page artwork metadata");
rejectDefinition(helper, "{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"weather\",\"value\":\"normal\"},\"poem\":\"only two\"}", "poem");
helper.succeed();
}
private static void rejectDefinition(GameTestHelper helper, String json, String diagnostic) {
boolean rejected = false;
try {
SymbolDefinition.parse(JsonParser.parseString(json));
} catch (IllegalArgumentException exception) {
rejected = exception.getMessage().contains(diagnostic);
}
helper.assertTrue(rejected, "Invalid symbol must be rejected with diagnostic containing '" + diagnostic + "'");
}
@GameTest(template = "empty") @GameTest(template = "empty")
public static void pageStatesPersistAndNetworkRoundTrip(GameTestHelper helper) { public static void pageStatesPersistAndNetworkRoundTrip(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard"); SymbolReference known = reference("rivencraft:terrain/standard");
@@ -8,10 +8,10 @@ import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog; import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata; import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.SymbolReference; import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.SymbolPoems;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
import net.minecraft.core.component.DataComponents; import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.world.item.component.CustomModelData;
import net.minecraft.world.item.Item; import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.item.TooltipFlag;
@@ -42,7 +42,7 @@ public final class PageItem extends Item {
page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), new SymbolDisplayMetadata( page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), new SymbolDisplayMetadata(
reference.id(), key, key + ".description", reference.id(), key, key + ".description",
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path.split("/")[0]), net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path.split("/")[0]),
true)); true, SymbolPoems.builtIn(reference.id())));
return page; return page;
} }
@@ -118,7 +118,6 @@ public final class PageItem extends Item {
private static ItemStack create(PageContent content) { private static ItemStack create(PageContent content) {
ItemStack stack = new ItemStack(ModItems.PAGE.get()); ItemStack stack = new ItemStack(ModItems.PAGE.get());
stack.set(ModDataComponents.PAGE_CONTENT.get(), content); stack.set(ModDataComponents.PAGE_CONTENT.get(), content);
stack.set(DataComponents.CUSTOM_MODEL_DATA, new CustomModelData(modelIndex(content)));
if (content.kind() == PageContent.Kind.SYMBOL) { if (content.kind() == PageContent.Kind.SYMBOL) {
stack.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), stack.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
SymbolCatalog.display(content.symbol().orElseThrow())); SymbolCatalog.display(content.symbol().orElseThrow()));
@@ -134,20 +133,4 @@ public final class PageItem extends Item {
: Component.translatable("item.rivencraft.page.unresolved"); : Component.translatable("item.rivencraft.page.unresolved");
} }
private static int modelIndex(PageContent content) {
return switch (content.kind()) {
case BLANK -> 0;
case LINK_PANEL -> 1;
case UNRESOLVED_SYMBOL -> 2;
case SYMBOL -> switch (content.symbol().orElseThrow().id().toString()) {
case "rivencraft:terrain/standard" -> 100;
case "rivencraft:biome/native" -> 101;
case "rivencraft:lighting/normal" -> 102;
case "rivencraft:weather/normal" -> 103;
case "rivencraft:celestial/normal_time" -> 104;
case "rivencraft:feature/caves" -> 105;
default -> 3;
};
};
}
} }
@@ -3,7 +3,6 @@ package net.dnorberg.rivencraft.writing;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.mojang.serialization.JsonOps;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -56,10 +55,13 @@ public final class SymbolCatalog {
ResourceManager resourceManager, ResourceManager resourceManager,
ProfilerFiller profiler) { ProfilerFiller profiler) {
Map<ResourceLocation, SymbolDefinition> loaded = new HashMap<>(); Map<ResourceLocation, SymbolDefinition> loaded = new HashMap<>();
resources.forEach((id, json) -> SymbolDefinition.CODEC.parse(JsonOps.INSTANCE, json) resources.forEach((id, json) -> {
.resultOrPartial(error -> Rivencraft.LOGGER.error( try {
"Ignoring invalid writing symbol {}: {}", id, error)) loaded.put(id, SymbolDefinition.parse(json));
.ifPresent(definition -> loaded.put(id, definition))); } catch (IllegalArgumentException exception) {
Rivencraft.LOGGER.error("Ignoring invalid writing symbol {}: {}", id, exception.getMessage());
}
});
symbols = Map.copyOf(loaded); symbols = Map.copyOf(loaded);
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size()); Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
} }
@@ -1,47 +1,127 @@
package net.dnorberg.rivencraft.writing; package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec; import com.google.gson.JsonElement;
import com.mojang.serialization.DataResult; import com.google.gson.JsonObject;
import com.mojang.serialization.codecs.RecordCodecBuilder; import com.google.gson.JsonPrimitive;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
/** Server-loaded, non-executable display metadata for a writing symbol. */ /** Server-only symbol definition. Only display metadata crosses to clients. */
public record SymbolDefinition( public record SymbolDefinition(
int schemaVersion, int schemaVersion,
String nameTranslationKey, String nameTranslationKey,
String descriptionTranslationKey, String descriptionTranslationKey,
ResourceLocation category ResourceLocation category,
Optional<SymbolSemantics> semantics,
String poem
) { ) {
public static final int CURRENT_SCHEMA_VERSION = 1; public static final int CURRENT_SCHEMA_VERSION = 2;
private static final Pattern TRANSLATION_KEY = Pattern.compile("[a-z0-9_.-]+"); private static final Pattern TRANSLATION_KEY = Pattern.compile("[a-z0-9_.-]+");
private static final Codec<String> TRANSLATION_KEY_CODEC = Codec.STRING.comapFlatMap( private static final Set<String> V2_FIELDS = Set.of(
value -> TRANSLATION_KEY.matcher(value).matches() "schema_version", "name", "description", "category", "semantics", "poem");
? DataResult.success(value)
: DataResult.error(() -> "Invalid translation key: " + value),
value -> value
);
public static final Codec<SymbolDefinition> CODEC = RecordCodecBuilder.<SymbolDefinition>create(instance -> instance.group(
Codec.INT.fieldOf("schema_version").forGetter(SymbolDefinition::schemaVersion),
TRANSLATION_KEY_CODEC.fieldOf("name").forGetter(SymbolDefinition::nameTranslationKey),
TRANSLATION_KEY_CODEC.fieldOf("description").forGetter(SymbolDefinition::descriptionTranslationKey),
ResourceLocation.CODEC.fieldOf("category").forGetter(SymbolDefinition::category)
).apply(instance, SymbolDefinition::new)).flatXmap(SymbolDefinition::validate, SymbolDefinition::validate);
public SymbolDefinition { public SymbolDefinition {
Objects.requireNonNull(nameTranslationKey, "nameTranslationKey"); Objects.requireNonNull(nameTranslationKey, "nameTranslationKey");
Objects.requireNonNull(descriptionTranslationKey, "descriptionTranslationKey"); Objects.requireNonNull(descriptionTranslationKey, "descriptionTranslationKey");
Objects.requireNonNull(category, "category"); Objects.requireNonNull(category, "category");
semantics = Objects.requireNonNull(semantics, "semantics");
poem = Objects.requireNonNull(poem, "poem");
if (schemaVersion != 1 && schemaVersion != CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported schema_version " + schemaVersion);
}
if (schemaVersion == 1 && semantics.isPresent()) {
throw new IllegalArgumentException("Schema v1 symbols cannot have semantics");
}
if (schemaVersion == CURRENT_SCHEMA_VERSION && semantics.isEmpty()) {
throw new IllegalArgumentException("Schema v2 symbols require semantics");
}
validateTranslationKey(nameTranslationKey, "name");
validateTranslationKey(descriptionTranslationKey, "description");
if (!poem.isEmpty() && (poem.length() > 96
|| !poem.matches("[A-Za-z]+(?: [A-Za-z]+){3}"))) {
throw new IllegalArgumentException("'poem' must contain exactly four alphabetic words");
}
}
/** v1 remains display-only; v2 has an exact field set and typed semantics. */
public static SymbolDefinition parse(JsonElement json) {
if (json == null || !json.isJsonObject()) {
throw new IllegalArgumentException("Symbol definition must be a JSON object");
}
JsonObject object = json.getAsJsonObject();
int version = requiredInteger(object, "schema_version");
if (version != 1 && version != CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported schema_version " + version);
}
if (version == CURRENT_SCHEMA_VERSION) {
for (String field : object.keySet()) {
if (!V2_FIELDS.contains(field)) {
throw new IllegalArgumentException("Unknown symbol field '" + field + "'");
}
}
} else if (object.has("semantics")) {
throw new IllegalArgumentException("Schema v1 cannot declare semantics; use schema_version 2");
}
String name = requiredString(object, "name");
String description = requiredString(object, "description");
ResourceLocation category = requiredResourceLocation(object, "category",
version == CURRENT_SCHEMA_VERSION);
Optional<SymbolSemantics> semantics = version == CURRENT_SCHEMA_VERSION
? Optional.of(SymbolSemantics.parse(object.get("semantics")))
: Optional.empty();
String poem = object.has("poem") ? requiredString(object, "poem") : "";
return new SymbolDefinition(version, name, description, category, semantics, poem);
} }
public SymbolDisplayMetadata display(ResourceLocation id) { public SymbolDisplayMetadata display(ResourceLocation id) {
return new SymbolDisplayMetadata(id, nameTranslationKey, descriptionTranslationKey, category, true); return new SymbolDisplayMetadata(id, nameTranslationKey, descriptionTranslationKey, category, true, poem);
} }
private static DataResult<SymbolDefinition> validate(SymbolDefinition definition) { static String requiredString(JsonObject object, String field) {
return definition.schemaVersion == CURRENT_SCHEMA_VERSION JsonElement element = object.get(field);
? DataResult.success(definition) if (element == null || !element.isJsonPrimitive()
: DataResult.error(() -> "Unsupported symbol schema_version " + definition.schemaVersion); || !element.getAsJsonPrimitive().isString()) {
throw new IllegalArgumentException("'" + field + "' must be a string");
}
return element.getAsString();
}
static ResourceLocation requiredResourceLocation(JsonObject object, String field) {
return requiredResourceLocation(object, field, true);
}
private static ResourceLocation requiredResourceLocation(JsonObject object, String field, boolean canonical) {
String value = requiredString(object, field);
ResourceLocation id = ResourceLocation.tryParse(value);
if (id == null || (canonical && !id.toString().equals(value))) {
throw new IllegalArgumentException("'" + field + "' must be "
+ (canonical ? "a canonical namespaced ID" : "a resource location") + ": " + value);
}
return id;
}
private static int requiredInteger(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null || !element.isJsonPrimitive()) {
throw new IllegalArgumentException("'" + field + "' must be an integer");
}
JsonPrimitive primitive = element.getAsJsonPrimitive();
if (!primitive.isNumber() || !primitive.getAsString().matches("-?\\d+")) {
throw new IllegalArgumentException("'" + field + "' must be an integer");
}
try {
return Integer.parseInt(primitive.getAsString());
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("'" + field + "' is outside the integer range", exception);
}
}
private static void validateTranslationKey(String value, String field) {
if (!TRANSLATION_KEY.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid '" + field + "' translation key: " + value);
}
} }
} }
@@ -14,14 +14,16 @@ public record SymbolDisplayMetadata(
String nameTranslationKey, String nameTranslationKey,
String descriptionTranslationKey, String descriptionTranslationKey,
ResourceLocation category, ResourceLocation category,
boolean resolved boolean resolved,
String poem
) { ) {
public static final Codec<SymbolDisplayMetadata> CODEC = RecordCodecBuilder.create(instance -> instance.group( public static final Codec<SymbolDisplayMetadata> CODEC = RecordCodecBuilder.create(instance -> instance.group(
ResourceLocation.CODEC.fieldOf("id").forGetter(SymbolDisplayMetadata::id), ResourceLocation.CODEC.fieldOf("id").forGetter(SymbolDisplayMetadata::id),
Codec.STRING.fieldOf("name").forGetter(SymbolDisplayMetadata::nameTranslationKey), Codec.STRING.fieldOf("name").forGetter(SymbolDisplayMetadata::nameTranslationKey),
Codec.STRING.fieldOf("description").forGetter(SymbolDisplayMetadata::descriptionTranslationKey), Codec.STRING.fieldOf("description").forGetter(SymbolDisplayMetadata::descriptionTranslationKey),
ResourceLocation.CODEC.fieldOf("category").forGetter(SymbolDisplayMetadata::category), ResourceLocation.CODEC.fieldOf("category").forGetter(SymbolDisplayMetadata::category),
Codec.BOOL.fieldOf("resolved").forGetter(SymbolDisplayMetadata::resolved) Codec.BOOL.fieldOf("resolved").forGetter(SymbolDisplayMetadata::resolved),
Codec.STRING.optionalFieldOf("poem", "").forGetter(SymbolDisplayMetadata::poem)
).apply(instance, SymbolDisplayMetadata::new)); ).apply(instance, SymbolDisplayMetadata::new));
public static final StreamCodec<ByteBuf, SymbolDisplayMetadata> STREAM_CODEC = StreamCodec.composite( public static final StreamCodec<ByteBuf, SymbolDisplayMetadata> STREAM_CODEC = StreamCodec.composite(
ResourceLocation.STREAM_CODEC, SymbolDisplayMetadata::id, ResourceLocation.STREAM_CODEC, SymbolDisplayMetadata::id,
@@ -29,6 +31,7 @@ public record SymbolDisplayMetadata(
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::descriptionTranslationKey, ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::descriptionTranslationKey,
ResourceLocation.STREAM_CODEC, SymbolDisplayMetadata::category, ResourceLocation.STREAM_CODEC, SymbolDisplayMetadata::category,
ByteBufCodecs.BOOL, SymbolDisplayMetadata::resolved, ByteBufCodecs.BOOL, SymbolDisplayMetadata::resolved,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::poem,
SymbolDisplayMetadata::new SymbolDisplayMetadata::new
); );
@@ -38,7 +41,8 @@ public record SymbolDisplayMetadata(
"symbol.rivencraft.unresolved", "symbol.rivencraft.unresolved",
"symbol.rivencraft.unresolved.description", "symbol.rivencraft.unresolved.description",
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "unresolved"), ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "unresolved"),
false false,
""
); );
} }
} }
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.writing;
/**
* Number components from Mystcraft Legacy's ModWords#constructNumber.
* Legacy registers 0–25; larger Rivencraft tab numbers use positional base 25.
*/
public final class SymbolNumerals {
private SymbolNumerals() {}
public static int[] digitsFor(int number) {
if (number < 0) throw new IllegalArgumentException("Numeral must be non-negative");
if (number < 25) return new int[] {number};
int count = 0;
for (int remaining = number; remaining > 0; remaining /= 25) count++;
int[] digits = new int[count];
for (int remaining = number, index = count - 1; index >= 0; index--, remaining /= 25)
digits[index] = remaining % 25;
return digits;
}
public static int[] componentsForDigit(int digit) {
if (digit < 0 || digit >= 25) throw new IllegalArgumentException("Numeral digit must be in [0, 24]");
if (digit == 0) return new int[] {1};
int fifths = digit / 5;
int remainder = digit % 5;
if (fifths == 0) return new int[] {55 + remainder};
if (remainder == 0) return new int[] {59 + fifths};
return new int[] {59 + fifths, 55 + remainder};
}
}
@@ -0,0 +1,21 @@
package net.dnorberg.rivencraft.writing;
import net.minecraft.resources.ResourceLocation;
/** Legacy poems for existing starter pages, including stacks saved before poem metadata existed. */
public final class SymbolPoems {
private SymbolPoems() {}
public static String builtIn(ResourceLocation id) {
if (!id.getNamespace().equals("rivencraft")) return "";
return switch (id.getPath()) {
case "terrain/standard" -> "Terrain Form Tradition Flow";
case "biome/native" -> "Constraint Nature Tradition Sustain";
case "lighting/normal" -> "Ethereal Dynamic Cycle Balance";
case "weather/normal" -> "Sustain Dynamic Tradition Balance";
case "celestial/normal_time" -> "Transform Time System Balance";
case "feature/caves" -> "Terrain Transform Void Flow";
default -> "";
};
}
}
@@ -0,0 +1,120 @@
package net.dnorberg.rivencraft.writing;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import java.util.Arrays;
import java.util.Objects;
import java.util.Set;
import net.minecraft.resources.ResourceLocation;
/** Bounded grammar role and value, retained by the server catalog only. */
public record SymbolSemantics(Role role, Value value) {
public SymbolSemantics {
Objects.requireNonNull(role, "role");
Objects.requireNonNull(value, "value");
if (!role.accepts(value)) {
throw new IllegalArgumentException("Invalid semantics.value for role '" + role.id
+ "': " + value + "; expected " + role.expected());
}
}
public static SymbolSemantics parse(JsonElement json) {
if (json == null || !json.isJsonObject()) {
throw new IllegalArgumentException("'semantics' must be an object");
}
JsonObject object = json.getAsJsonObject();
for (String field : object.keySet()) {
if (!field.equals("role") && !field.equals("value")) {
throw new IllegalArgumentException("Unknown semantics field '" + field + "'");
}
}
String roleId = SymbolDefinition.requiredString(object, "role");
Role role = Arrays.stream(Role.values()).filter(candidate -> candidate.id.equals(roleId))
.findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown semantic role '" + roleId + "'"));
JsonElement raw = object.get("value");
if (raw == null || !raw.isJsonPrimitive()) {
throw new IllegalArgumentException("'semantics.value' is required");
}
JsonPrimitive primitive = raw.getAsJsonPrimitive();
Value value;
if (role.kind == Kind.RESOURCE) {
value = new ResourceValue(SymbolDefinition.requiredResourceLocation(object, "value"));
} else if (role.kind == Kind.NUMBER) {
if (!primitive.isNumber()) {
throw new IllegalArgumentException("'semantics.value' for " + role.id + " must be a number");
}
value = new NumberValue(primitive.getAsDouble());
} else {
value = new ChoiceValue(SymbolDefinition.requiredString(object, "value"));
}
return new SymbolSemantics(role, value);
}
public sealed interface Value permits ResourceValue, ChoiceValue, NumberValue {}
public record ResourceValue(ResourceLocation id) implements Value {
public ResourceValue { Objects.requireNonNull(id, "id"); }
}
public record ChoiceValue(String id) implements Value {
public ChoiceValue { Objects.requireNonNull(id, "id"); }
}
public record NumberValue(double amount) implements Value {
public NumberValue {
if (!Double.isFinite(amount)) {
throw new IllegalArgumentException("Semantic numeric value must be finite");
}
}
}
private enum Kind { RESOURCE, CHOICE, NUMBER }
public enum Role {
TERRAIN("terrain", Kind.RESOURCE),
BIOME_CONTROLLER("biome_controller", Kind.CHOICE,
"native", "single", "tiled", "grid", "tiny", "small", "medium", "large", "huge"),
BIOME("biome", Kind.RESOURCE),
LIGHTING("lighting", Kind.CHOICE, "normal", "dark", "bright"),
WEATHER("weather", Kind.CHOICE,
"normal", "fast", "slow", "off", "always", "cloudy", "rain", "snow", "storm"),
CELESTIAL("celestial", Kind.CHOICE, "normal_time"),
PERIOD_MODIFIER("period_modifier", Kind.NUMBER),
ANGLE_MODIFIER("angle_modifier", Kind.NUMBER),
PHASE_MODIFIER("phase_modifier", Kind.NUMBER),
FEATURE("feature", Kind.RESOURCE);
private final String id;
private final Kind kind;
private final Set<String> choices;
Role(String id, Kind kind, String... choices) {
this.id = id;
this.kind = kind;
this.choices = Set.of(choices);
}
public String id() { return id; }
private boolean accepts(Value value) {
if (kind == Kind.RESOURCE) return value instanceof ResourceValue;
if (kind == Kind.CHOICE) return value instanceof ChoiceValue choice && choices.contains(choice.id());
if (!(value instanceof NumberValue number)) return false;
return switch (this) {
case PERIOD_MODIFIER -> number.amount() >= 0.0 && number.amount() <= 2.0;
case ANGLE_MODIFIER, PHASE_MODIFIER -> number.amount() >= 0.0 && number.amount() < 360.0;
default -> false;
};
}
private String expected() {
return switch (kind) {
case RESOURCE -> "a canonical namespaced ID";
case CHOICE -> "one of " + choices.stream().sorted().toList();
case NUMBER -> this == PERIOD_MODIFIER ? "a finite number in [0, 2]"
: "a finite number in [0, 360) degrees";
};
}
}
}
@@ -42,6 +42,7 @@
"screen.rivencraft.writing_desk.all_categories": "All categories", "screen.rivencraft.writing_desk.all_categories": "All categories",
"screen.rivencraft.writing_desk.previous_tabs": "Previous four tabs (Page Up)", "screen.rivencraft.writing_desk.previous_tabs": "Previous four tabs (Page Up)",
"screen.rivencraft.writing_desk.next_tabs": "Next four tabs (Page Down)", "screen.rivencraft.writing_desk.next_tabs": "Next four tabs (Page Down)",
"screen.rivencraft.writing_desk.tab_symbol": "Tab %s",
"screen.rivencraft.writing_desk.no_symbols": "No available symbols", "screen.rivencraft.writing_desk.no_symbols": "No available symbols",
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s", "item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
"item.rivencraft.ink_vial.usage": "Use with paper in the other hand to prepare a Link Panel.", "item.rivencraft.ink_vial.usage": "Use with paper in the other hand to prepare a Link Panel.",
@@ -1,17 +1,13 @@
{ {
"parent": "minecraft:item/generated", "parent": "builtin/entity",
"textures": { "gui_light": "front",
"layer0": "rivencraft:item/page" "display": {
}, "ground": { "translation": [0, 2, 0], "scale": [0.5, 0.5, 0.5] },
"overrides": [ "head": { "rotation": [0, 180, 0], "translation": [0, 13, 7] },
{ "predicate": { "custom_model_data": 1 }, "model": "rivencraft:item/page_link_panel" }, "thirdperson_righthand": { "translation": [0, 3, 1], "scale": [0.55, 0.55, 0.55] },
{ "predicate": { "custom_model_data": 2 }, "model": "rivencraft:item/page_unresolved" }, "thirdperson_lefthand": { "translation": [0, 3, 1], "scale": [0.55, 0.55, 0.55] },
{ "predicate": { "custom_model_data": 3 }, "model": "rivencraft:item/page_symbol" }, "firstperson_righthand": { "rotation": [0, -90, 25], "translation": [1.13, 3.2, 1.13], "scale": [0.68, 0.68, 0.68] },
{ "predicate": { "custom_model_data": 100 }, "model": "rivencraft:item/page_symbol_terrain_standard" }, "firstperson_lefthand": { "rotation": [0, 90, -25], "translation": [1.13, 3.2, 1.13], "scale": [0.68, 0.68, 0.68] },
{ "predicate": { "custom_model_data": 101 }, "model": "rivencraft:item/page_symbol_biome_native" }, "fixed": { "rotation": [0, 180, 0] }
{ "predicate": { "custom_model_data": 102 }, "model": "rivencraft:item/page_symbol_lighting_normal" }, }
{ "predicate": { "custom_model_data": 103 }, "model": "rivencraft:item/page_symbol_weather_normal" },
{ "predicate": { "custom_model_data": 104 }, "model": "rivencraft:item/page_symbol_celestial_normal_time" },
{ "predicate": { "custom_model_data": 105 }, "model": "rivencraft:item/page_symbol_feature_caves" }
]
} }
@@ -1,4 +0,0 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_biome_native" }
}
@@ -1,4 +0,0 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_celestial_normal_time" }
}
@@ -1,4 +0,0 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_feature_caves" }
}
@@ -1,4 +0,0 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_lighting_normal" }
}
@@ -1,4 +0,0 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_terrain_standard" }
}
@@ -1,4 +0,0 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_weather_normal" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,6 +1,8 @@
{ {
"schema_version": 1, "schema_version": 2,
"name": "symbol.rivencraft.biome.native", "name": "symbol.rivencraft.biome.native",
"description": "symbol.rivencraft.biome.native.description", "description": "symbol.rivencraft.biome.native.description",
"category": "rivencraft:biome" "category": "rivencraft:biome",
"semantics": { "role": "biome_controller", "value": "native" },
"poem": "Constraint Nature Tradition Sustain"
} }
@@ -1,6 +1,8 @@
{ {
"schema_version": 1, "schema_version": 2,
"name": "symbol.rivencraft.celestial.normal_time", "name": "symbol.rivencraft.celestial.normal_time",
"description": "symbol.rivencraft.celestial.normal_time.description", "description": "symbol.rivencraft.celestial.normal_time.description",
"category": "rivencraft:celestial" "category": "rivencraft:celestial",
"semantics": { "role": "celestial", "value": "normal_time" },
"poem": "Transform Time System Balance"
} }
@@ -1,6 +1,8 @@
{ {
"schema_version": 1, "schema_version": 2,
"name": "symbol.rivencraft.feature.caves", "name": "symbol.rivencraft.feature.caves",
"description": "symbol.rivencraft.feature.caves.description", "description": "symbol.rivencraft.feature.caves.description",
"category": "rivencraft:feature" "category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:caves" },
"poem": "Terrain Transform Void Flow"
} }
@@ -1,6 +1,8 @@
{ {
"schema_version": 1, "schema_version": 2,
"name": "symbol.rivencraft.lighting.normal", "name": "symbol.rivencraft.lighting.normal",
"description": "symbol.rivencraft.lighting.normal.description", "description": "symbol.rivencraft.lighting.normal.description",
"category": "rivencraft:lighting" "category": "rivencraft:lighting",
"semantics": { "role": "lighting", "value": "normal" },
"poem": "Ethereal Dynamic Cycle Balance"
} }
@@ -1,6 +1,8 @@
{ {
"schema_version": 1, "schema_version": 2,
"name": "symbol.rivencraft.terrain.standard", "name": "symbol.rivencraft.terrain.standard",
"description": "symbol.rivencraft.terrain.standard.description", "description": "symbol.rivencraft.terrain.standard.description",
"category": "rivencraft:terrain" "category": "rivencraft:terrain",
"semantics": { "role": "terrain", "value": "rivencraft:standard" },
"poem": "Terrain Form Tradition Flow"
} }
@@ -1,6 +1,8 @@
{ {
"schema_version": 1, "schema_version": 2,
"name": "symbol.rivencraft.weather.normal", "name": "symbol.rivencraft.weather.normal",
"description": "symbol.rivencraft.weather.normal.description", "description": "symbol.rivencraft.weather.normal.description",
"category": "rivencraft:weather" "category": "rivencraft:weather",
"semantics": { "role": "weather", "value": "normal" },
"poem": "Sustain Dynamic Tradition Balance"
} }