Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27abebe3aa | ||
|
|
5538ca6aa6 | ||
|
|
3c900634c9 | ||
|
|
8cce41fd71 | ||
|
|
114a6ee21e | ||
|
|
8246640f49 |
No files matched your search
@@ -4,8 +4,8 @@ Rivencraft is a clean, incremental port of the ideas and gameplay of
|
||||
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
|
||||
1.21.1 on NeoForge.
|
||||
|
||||
The bootstrap, books/linking, and Ages milestones are complete. Milestone 4 is
|
||||
building the writing system on a data-pack symbol catalog and persistent
|
||||
The bootstrap, books/linking, Ages, and writing-system milestones are complete.
|
||||
The writing system uses a data-pack symbol catalog and persistent
|
||||
ordered-writing model. The project currently provides functional Linking Books
|
||||
and randomly seeded, persistent vanilla-like Ages created from page-free
|
||||
Descriptive Books. Instability and full symbol-to-world grammar interpretation
|
||||
@@ -189,6 +189,10 @@ known upstream limits are documented in
|
||||
```
|
||||
|
||||
The built mod is written to `build/libs/`.
|
||||
The default dedicated-server GameTest run is deliberately small. Use
|
||||
`./gradlew runGameTestServer -PwithArchivedGameTests=true` for the complete
|
||||
regression suite before a release or when investigating a related issue. See
|
||||
[docs/GAMETESTS.md](docs/GAMETESTS.md).
|
||||
|
||||
## Versioning
|
||||
|
||||
|
||||
@@ -17,6 +17,18 @@ tasks.named('wrapper', Wrapper).configure {
|
||||
version = mod_version
|
||||
group = mod_group_id
|
||||
|
||||
List<String> archivedGameTestSuites = ['linking', 'writing', 'grammar', 'desk', 'binder', 'mixer', 'recipe']
|
||||
List<String> selectedArchivedGameTestSuites = providers.gradleProperty('withArchivedGameTests')
|
||||
.map { it.toBoolean() }.orElse(false).get() ? archivedGameTestSuites
|
||||
: providers.gradleProperty('archivedGameTestSuites').orElse('').get()
|
||||
.split(',').collect { it.trim() }.findAll { !it.isEmpty() }
|
||||
if (!archivedGameTestSuites.containsAll(selectedArchivedGameTestSuites)) {
|
||||
throw new GradleException("Unknown archived GameTest suite. Choose from: ${archivedGameTestSuites.join(', ')}")
|
||||
}
|
||||
String enabledGameTestNamespaces = ([mod_id] + selectedArchivedGameTestSuites.collect {
|
||||
"rivencraft_archive_${it}"
|
||||
}).join(',')
|
||||
|
||||
sourceSets.main.resources {
|
||||
// Include resources generated by data generators.
|
||||
srcDir('src/generated/resources')
|
||||
@@ -26,6 +38,15 @@ sourceSets.main.resources {
|
||||
exclude("src/generated/**/.cache") // datagen cache files
|
||||
}
|
||||
|
||||
// Archived suites use separate namespaces but share the same empty structure.
|
||||
tasks.named('processResources', ProcessResources).configure {
|
||||
archivedGameTestSuites.each { suite ->
|
||||
from('src/main/resources/data/rivencraft/structure/empty.nbt') {
|
||||
into "data/rivencraft_archive_${suite}/structure"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
name = 'JaredsMaven'
|
||||
@@ -89,7 +110,7 @@ neoForge {
|
||||
// Keep generated test Ages separate from worlds used by the dev client/server.
|
||||
gameDirectory = project.file(project.findProperty('gameTestRunDir')
|
||||
?: layout.buildDirectory.dir('gametest-run').get().asFile)
|
||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||
systemProperty 'neoforge.enabledGameTestNamespaces', enabledGameTestNamespaces
|
||||
}
|
||||
|
||||
data {
|
||||
|
||||
@@ -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/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_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
|
||||
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# GameTest suites
|
||||
|
||||
The default dedicated-server run is a small smoke suite: 19 Rivencraft tests
|
||||
cover core linking, deterministic Age definition, writing persistence, legacy
|
||||
data migration, workstation transactions, and forged/replayed menu actions.
|
||||
NeoForge currently adds two of its own tests, so the runner reports 21.
|
||||
|
||||
```shell
|
||||
./gradlew runGameTestServer
|
||||
```
|
||||
|
||||
The remaining 49 Rivencraft tests are archived, not deleted. They cover
|
||||
completed milestone details such as every Age terrain profile, Notebook and
|
||||
Desk surface interactions, refill edge cases, book assembly controls, and
|
||||
workstation break/drop behavior. Run only the relevant archive alongside the
|
||||
core tests when investigating an issue:
|
||||
|
||||
```shell
|
||||
./gradlew runGameTestServer -ParchivedGameTestSuites=desk
|
||||
./gradlew runGameTestServer -ParchivedGameTestSuites=grammar,binder
|
||||
```
|
||||
|
||||
Available suite names are `linking`, `writing`, `grammar`, `desk`, `binder`, `mixer`, and
|
||||
`recipe`. Run all 68 Rivencraft tests (70 including NeoForge's two) before a
|
||||
release or after broad cross-system changes:
|
||||
|
||||
```shell
|
||||
./gradlew runGameTestServer -PwithArchivedGameTests=true
|
||||
```
|
||||
|
||||
Each archive is selected by its `rivencraft_archive_<suite>` GameTest namespace.
|
||||
The suites share the normal empty test structure and do not need Create, Ponder, or
|
||||
Immersive Portals. The core suite delegates to the original regression methods
|
||||
for its selected cases, so the two suites do not maintain duplicate test logic.
|
||||
|
||||
GameTest Ages are created in `build/gametest-run/world`, not in a development
|
||||
client save. The Gradle task removes that **dedicated** world automatically if
|
||||
it exceeds 512 MiB; override the threshold with `-PgameTestMaxWorldMiB=128`,
|
||||
for example. Cleanup never targets a custom `-PgameTestRunDir` or a player
|
||||
world. A fresh test world can also help diagnose a slow or stalled worldgen
|
||||
run. The full suite should still be run before shipping changes in the
|
||||
archived systems.
|
||||
@@ -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.
|
||||
@@ -35,10 +35,12 @@ acceptance issue and all required implementation issues are complete.
|
||||
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
|
||||
4. **Writing system** — pages, notebooks, desks, book assembly, and the baseline
|
||||
Ink Mixer for Link Panels. Symbol effects are deferred until Milestone 5.
|
||||
**In progress.**
|
||||
**Complete.** See [Milestone 4](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/9)
|
||||
and [writing-system data and migration](WRITING_DATA.md).
|
||||
5. **Age grammar** — translate symbol combinations into modern biome, noise,
|
||||
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.
|
||||
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
|
||||
sounds, advancements, compatibility, and migration tests.
|
||||
@@ -63,11 +65,11 @@ The writing system and Age grammar will build on the existing page-free Age
|
||||
path. Page-free books remain a useful deterministic fallback and test fixture
|
||||
after written symbol combinations become available.
|
||||
|
||||
Milestone 4 begins with a versioned, data-pack-defined symbol catalog and an
|
||||
Milestone 4 delivered a versioned, data-pack-defined symbol catalog and an
|
||||
immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These
|
||||
contracts preserve unknown symbol identifiers and store author intent; they do
|
||||
not interpret symbols into Age generation, which remains Milestone 5 work.
|
||||
The Ink Mixer is included in Milestone 4 after book assembly; its first release
|
||||
The Ink Mixer is included in Milestone 4 after book assembly; it
|
||||
uses paper and Ink Vials to make baseline Link Panels. Adding modifier
|
||||
ingredients and symbol-driven link properties belongs to Milestone 5.
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ unstable in a particular graphics or mod configuration.
|
||||
|
||||
For preview-related releases:
|
||||
|
||||
1. Run `./gradlew runGameTestServer` to verify the complete selection matrix,
|
||||
1. Run `./gradlew runGameTestServer -PwithArchivedGameTests=true` to verify the complete selection matrix,
|
||||
core Age creation, persistence, and travel without optional renderers.
|
||||
2. Open held Linking and Descriptive Books once with each available backend.
|
||||
3. Open both book types from a Book Stand and confirm Explore is disabled.
|
||||
@@ -75,4 +75,3 @@ For preview-related releases:
|
||||
preview loader remains active.
|
||||
5. With Ponder, reopen different destinations and confirm the previous
|
||||
snapshot does not bleed into the next one.
|
||||
|
||||
@@ -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 Age grammar parsing, 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. The [ordered-writing parser](WRITING_GRAMMAR.md) validates new books, but does **not yet apply** written symbols to Age generation; issue #31 adds 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.
|
||||
@@ -1,9 +1,11 @@
|
||||
# Writing and symbol data
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -25,13 +27,17 @@ Schema version 1 contains display metadata only:
|
||||
|
||||
`name` and `description` are translation keys, not arbitrary formatted text or
|
||||
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
|
||||
page synchronization must obtain resolved display metadata from the server;
|
||||
clients never make a symbol valid by naming it.
|
||||
|
||||
Rivencraft ships a small catalog covering terrain, biomes, lighting, weather,
|
||||
celestial behavior, and features. It exists to exercise the complete writing
|
||||
workflow before Milestone 5 implements the full legacy grammar.
|
||||
celestial behavior, and features. Its v2 semantics prepare for the full
|
||||
Legacy-inspired grammar; writing still uses random Age fallback until the
|
||||
parser and compiler are implemented.
|
||||
|
||||
## Persistent writing
|
||||
|
||||
@@ -65,11 +71,11 @@ unresolved symbol, and baseline link panel. Symbol and unresolved-symbol pages
|
||||
retain their namespaced symbol reference; unresolved pages can be inspected and
|
||||
removed but are rejected as usable writing until their data pack returns.
|
||||
|
||||
Only server-side writing operations may create a known symbol
|
||||
page or link panel from vanilla paper. The shared `PageItem` creation and validation gates reject
|
||||
new pages for identifiers absent from the server catalog. Later Writing Desk
|
||||
and Book Binder packets must call these gates rather than accepting page
|
||||
contents supplied by a client.
|
||||
Only server-side writing operations may create a known symbol page or link
|
||||
panel from vanilla paper. The shared `PageItem` creation and validation gates
|
||||
reject new pages for identifiers absent from the server catalog. The Writing
|
||||
Desk refuses to copy an unresolved page, and the Book Binder keeps such a page
|
||||
removable but will not assemble a book from it until its symbol resolves again.
|
||||
|
||||
The item model uses server-assigned custom-model data derived from the symbol
|
||||
identifier. Built-in pages reproduce Mystcraft Legacy's four-word poem
|
||||
@@ -84,6 +90,29 @@ placement. The surface draws the native 30×40 portrait page region from
|
||||
Legacy's book UI atlas; symbols are layered at a uniform scale, while the
|
||||
square item models remain unchanged in inventories.
|
||||
|
||||
A Notebook holds 27 ordered positions. Its pages, names, gaps, and unresolved
|
||||
IDs are saved on the item, so moving the Notebook between an inventory, Desk,
|
||||
and Binder does not rearrange them. A loose Page in a Desk tab exposes just one
|
||||
position; a Notebook exposes all 27. The Desk has 88 persistent collection
|
||||
tabs, shown four at a time. Use the up/down tab controls to move by four, put
|
||||
paper and Ink Vials in their input slots, select a stored page, and take the
|
||||
copy from the virtual output. Taking it spends one paper and the configured
|
||||
symbol-copy ink cost. The `AZ`, `ALL`, search, and category controls only change
|
||||
which stored pages are displayed; the server rechecks the actual source before
|
||||
copying. Moving a page within the Desk or dragging it into the inventory removes
|
||||
the original only when the destination accepts it.
|
||||
|
||||
The Book Binder takes a leather or empty-Notebook cover, an Age title, one Link
|
||||
Panel, and optionally up to 27 ordered written Pages. A filled Notebook can be
|
||||
imported at a chosen strip position; the empty Notebook remains recoverable.
|
||||
The preview result is virtual. Taking the completed Descriptive Book commits the
|
||||
inputs once, creates its Age identity, and saves schema-versioned
|
||||
`OrderedWriting` plus the Age reference on the book. Link-Panel-only books
|
||||
create random Ages. A written symbol does **not** yet change that generation.
|
||||
Inputs, title, and pending Age identity survive Binder save/load; breaking the
|
||||
Binder returns stored inputs. The completed book retains both its writing and
|
||||
Age identity through item save/load and copying.
|
||||
|
||||
## Ink and baseline link panels
|
||||
|
||||
An Ink Vial is crafted from two black dye, a glass bottle, and a water bucket.
|
||||
@@ -103,3 +132,32 @@ resolved page, then spends paper and ink only when that copy is taken. The
|
||||
desk's 16-unit ink meter refills from an Ink Vial when needed and returns a
|
||||
vanilla glass bottle. Link-property modifiers are
|
||||
deferred beyond this baseline flow.
|
||||
|
||||
The Ink Mixer is the station version of Link Panel preparation. Put paper and
|
||||
Ink Vials in its inputs and take the Link Panel from its virtual output. Its
|
||||
16-unit basin refills only when needed and only if the glass-bottle output has
|
||||
space. The basin and inputs persist through save/load; breaking the Mixer
|
||||
returns stored items. A Link Panel plus leather also crafts an unwritten
|
||||
Linking Book. JEI, when installed, displays the Mixer operation and the
|
||||
writing, binding, and Linking Book crafting flows; it is not required to play.
|
||||
|
||||
## Migration and validation
|
||||
|
||||
The 0.3.7.2 bare-list `OrderedWriting` form reads as schema version 1 without
|
||||
changing entry order. Descriptive Books with no writing component keep their
|
||||
existing Age behavior. Old eight-slot Desks migrate their four collections,
|
||||
ink supply, paper, bottle return, and legacy output into the 88-tab layout;
|
||||
saved inputs are never silently discarded. Missing symbol IDs survive page,
|
||||
Notebook, Binder, and assembled-book persistence instead of being substituted
|
||||
or erased.
|
||||
|
||||
The core and archived dedicated-server [GameTests](GAMETESTS.md) together
|
||||
exercise codec and item round-trips, legacy data,
|
||||
workstation NBT reload, break drops, full-inventory refusal, forged menu IDs,
|
||||
and replayed output requests. World saves and chunk unloads use the same block
|
||||
entity NBT persistence path as those reload tests. This does not replace a
|
||||
manual smoke test of loading an older world before updating a production save.
|
||||
|
||||
Milestone 4 stops at preserving author intent and offering a playable random
|
||||
Age fallback. Milestone 5 will define symbol grammar and validation, translate
|
||||
valid writing into actual Age generation, and introduce symbol and ink modifiers.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Ordered Descriptive Book grammar (Milestone 5, issue #30)
|
||||
|
||||
`WritingGrammar` interprets the saved `OrderedWriting` on the server using only `SymbolCatalog`'s schema-v2 semantics. Client display metadata, page names, and supplied translations never determine an Age's meaning. The parser is pure: it returns immutable parsed entries and indexed diagnostics without changing a book or its pages.
|
||||
|
||||
New Binder writing has at most one Link Panel plus 27 symbol pages. The Link Panel must be first. A Link-Panel-only book, or an older book with no writing component, keeps the existing random Age path. Any authored supported symbol makes the book a partial or complete grammar, even though **0.4.2.0 still generates a random Age**; issue #31 will compile it into a persistent plan before chunk generation.
|
||||
|
||||
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; lighting; zero or more period, angle, or phase modifiers; one celestial page; then zero or more distinct features. Biome pages precede the controller that consumes them, and celestial modifiers precede the celestial page. Terrain, controller, weather, lighting, and celestial are single-choice categories; identical repeats are duplicates and differing values conflict. Repeated modifiers are intentional. Biome pages may repeat, preserving author order and any future weighting; feature IDs may not repeat.
|
||||
|
||||
Missing terrain, controller, weather, lighting, celestial, or the controller's minimum biome count is an **incomplete** warning, not an error: issue #31 will fill those branches deterministically from the Age seed. The minimum is zero for Native, one for Single, two for Tiled/Grid, and three for Tiny–Huge. Native cannot consume authored biome pages; Single accepts only one. Unknown IDs, schema-v1 display-only symbols, wrong ordering, misplaced modifiers, duplicates, 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.
|
||||
|
||||
This slice does not score instability, synthesize missing symbols, or apply symbol effects. Those responsibilities belong to issues #31–#35 and Milestone 6. Existing bound books are not revalidated or migrated merely because a data pack changes.
|
||||
@@ -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.3.9.2
|
||||
mod_version=0.4.2.1
|
||||
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.
|
||||
# This should match the base package used for the mod sources.
|
||||
|
||||
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
@@ -105,10 +106,43 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
||||
|| items.get(LINK_PANEL).getCount() != 1) return false;
|
||||
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
|
||||
ItemStack page = items.get(slot);
|
||||
if (!page.isEmpty() && (!validPage(page) || page.getCount() != 1
|
||||
if (!page.isEmpty() && (!validPage(page) || !PageItem.isUsableWriting(page) || page.getCount() != 1
|
||||
|| PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false;
|
||||
}
|
||||
return true;
|
||||
return WritingGrammar.parse(currentWriting()).canCreate();
|
||||
}
|
||||
|
||||
public WritingGrammar.Result writingGrammar() {
|
||||
return WritingGrammar.parse(currentWriting());
|
||||
}
|
||||
|
||||
/** Map the parser's compact entry index back to the visible strip, including empty page positions. */
|
||||
public int writingStripIndex(int grammarIndex) {
|
||||
if (grammarIndex < 0) return -1;
|
||||
int entryIndex = 0;
|
||||
if (canPlaceItem(LINK_PANEL, items.get(LINK_PANEL))) {
|
||||
if (grammarIndex == 0) return 0;
|
||||
entryIndex++;
|
||||
}
|
||||
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
|
||||
ItemStack page = items.get(slot);
|
||||
if (!page.isEmpty() && PageItem.content(page).symbol().isPresent()) {
|
||||
if (entryIndex == grammarIndex) return slot - FIRST_PAGE + 1;
|
||||
entryIndex++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private OrderedWriting currentWriting() {
|
||||
List<WritingEntry> entries = new ArrayList<>();
|
||||
if (canPlaceItem(LINK_PANEL, items.get(LINK_PANEL))) entries.add(WritingEntry.linkPanel());
|
||||
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
|
||||
ItemStack page = items.get(slot);
|
||||
if (!page.isEmpty()) PageItem.content(page).symbol().ifPresent(reference ->
|
||||
entries.add(WritingEntry.symbol(reference)));
|
||||
}
|
||||
return new OrderedWriting(entries);
|
||||
}
|
||||
|
||||
public ItemStack previewBook() {
|
||||
@@ -117,15 +151,9 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
||||
pendingAgeId = UUID.randomUUID();
|
||||
setChanged();
|
||||
}
|
||||
List<WritingEntry> entries = new ArrayList<>();
|
||||
entries.add(WritingEntry.linkPanel());
|
||||
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
|
||||
ItemStack page = items.get(slot);
|
||||
if (!page.isEmpty()) entries.add(WritingEntry.symbol(PageItem.content(page).symbol().orElseThrow()));
|
||||
}
|
||||
ItemStack book = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
|
||||
book.set(ModDataComponents.AGE_REFERENCE.get(), new AgeReference(pendingAgeId, title));
|
||||
book.set(ModDataComponents.BOOK_WRITING.get(), new OrderedWriting(entries));
|
||||
book.set(ModDataComponents.BOOK_WRITING.get(), currentWriting());
|
||||
book.set(DataComponents.CUSTOM_NAME, Component.literal(title));
|
||||
return book;
|
||||
}
|
||||
@@ -195,26 +223,19 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
||||
if (pages.isEmpty()) return ItemStack.EMPTY;
|
||||
int end = nextPageIndex();
|
||||
if (index < 0 || index > end) return ItemStack.EMPTY;
|
||||
boolean link = false;
|
||||
ItemStack linkPanel = ItemStack.EMPTY;
|
||||
List<ItemStack> writing = new ArrayList<>();
|
||||
for (ItemStack page : pages) {
|
||||
if (page.isEmpty()) continue;
|
||||
if (!page.is(ModItems.PAGE.get()) || page.getCount() != 1) return ItemStack.EMPTY;
|
||||
if (PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) {
|
||||
if (link || !items.get(LINK_PANEL).isEmpty()) return ItemStack.EMPTY;
|
||||
link = true;
|
||||
if (!linkPanel.isEmpty() || !items.get(LINK_PANEL).isEmpty()) return ItemStack.EMPTY;
|
||||
linkPanel = page;
|
||||
} else if (validPage(page)) writing.add(page);
|
||||
else return ItemStack.EMPTY;
|
||||
}
|
||||
if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && !link) return ItemStack.EMPTY;
|
||||
if (link) {
|
||||
for (ItemStack page : pages) {
|
||||
if (!page.isEmpty() && PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) {
|
||||
items.set(LINK_PANEL, page.copy());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && linkPanel.isEmpty()) return ItemStack.EMPTY;
|
||||
if (!linkPanel.isEmpty()) items.set(LINK_PANEL, linkPanel.copy());
|
||||
for (int current = end - 1; current >= index; current--)
|
||||
items.set(FIRST_PAGE + current + writing.size(), items.get(FIRST_PAGE + current));
|
||||
for (ItemStack page : writing) items.set(FIRST_PAGE + index++, page.copy());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||
import net.dnorberg.rivencraft.writing.InkVialExchange;
|
||||
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||
@@ -64,35 +65,21 @@ public final class InkMixerBlockEntity extends BlockEntity implements Container
|
||||
worldPosition.getZ() + 0.5) <= 64.0;
|
||||
}
|
||||
|
||||
private boolean hasBottleSpace() {
|
||||
ItemStack bottles = items.get(BOTTLES);
|
||||
return bottles.isEmpty() || bottles.is(Items.GLASS_BOTTLE)
|
||||
&& bottles.getCount() < bottles.getMaxStackSize();
|
||||
}
|
||||
|
||||
/** No server tick: refill only when ink, bottle space, or consumption changes. */
|
||||
private void refillEmptyBasin() {
|
||||
if (level == null || level.isClientSide || inkRemaining != 0 || !hasBottleSpace()) return;
|
||||
ItemStack vial = items.get(INK);
|
||||
if (!vial.is(ModItems.INK_VIAL.get())) return;
|
||||
int amount = Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
|
||||
if (amount == 0) return;
|
||||
inkRemaining = amount;
|
||||
vial.shrink(1);
|
||||
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
|
||||
ItemStack bottles = items.get(BOTTLES);
|
||||
if (bottles.isEmpty()) items.set(BOTTLES, new ItemStack(Items.GLASS_BOTTLE));
|
||||
else bottles.grow(1);
|
||||
setChanged();
|
||||
if (level != null && !level.isClientSide && inkRemaining == 0) {
|
||||
int addedInk = InkVialExchange.exchange(items, INK, BOTTLES);
|
||||
if (addedInk > 0) {
|
||||
inkRemaining = addedInk;
|
||||
setChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean canCreateLinkPanel() {
|
||||
if (!items.get(PAPER).is(Items.PAPER)) return false;
|
||||
int cost = WritingInkService.linkPanelCost();
|
||||
if (inkRemaining >= cost) return true;
|
||||
ItemStack vial = items.get(INK);
|
||||
return vial.is(ModItems.INK_VIAL.get()) && hasBottleSpace()
|
||||
&& inkRemaining + InkVialItem.remaining(vial) >= cost;
|
||||
return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, cost);
|
||||
}
|
||||
|
||||
/** Commit inputs only when a virtual output is actually taken. */
|
||||
@@ -100,13 +87,7 @@ public final class InkMixerBlockEntity extends BlockEntity implements Container
|
||||
if (!canCreateLinkPanel()) return false;
|
||||
int cost = WritingInkService.linkPanelCost();
|
||||
if (inkRemaining < cost) {
|
||||
ItemStack vial = items.get(INK);
|
||||
inkRemaining += Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
|
||||
vial.shrink(1);
|
||||
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
|
||||
ItemStack bottles = items.get(BOTTLES);
|
||||
if (bottles.isEmpty()) items.set(BOTTLES, new ItemStack(Items.GLASS_BOTTLE));
|
||||
else bottles.grow(1);
|
||||
inkRemaining += InkVialExchange.exchange(items, INK, BOTTLES);
|
||||
}
|
||||
inkRemaining -= cost;
|
||||
items.get(PAPER).shrink(1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||
import net.dnorberg.rivencraft.writing.InkVialExchange;
|
||||
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
@@ -92,26 +93,15 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
|
||||
};
|
||||
}
|
||||
|
||||
private boolean hasBottleSpace() {
|
||||
ItemStack returned = items.get(RETURN);
|
||||
return returned.isEmpty() || returned.is(Items.GLASS_BOTTLE)
|
||||
&& returned.getCount() < returned.getMaxStackSize();
|
||||
}
|
||||
|
||||
/** Consume one vial only when the empty meter can return its glass bottle. */
|
||||
private void refillEmptyInkBar() {
|
||||
if (level == null || level.isClientSide || inkRemaining != 0 || !hasBottleSpace()) return;
|
||||
ItemStack vial = items.get(INK);
|
||||
if (!vial.is(ModItems.INK_VIAL.get())) return;
|
||||
int ink = Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
|
||||
if (ink == 0) return;
|
||||
inkRemaining = ink;
|
||||
vial.shrink(1);
|
||||
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
|
||||
ItemStack returned = items.get(RETURN);
|
||||
if (returned.isEmpty()) items.set(RETURN, new ItemStack(Items.GLASS_BOTTLE));
|
||||
else returned.grow(1);
|
||||
setChanged();
|
||||
if (level != null && !level.isClientSide && inkRemaining == 0) {
|
||||
int addedInk = InkVialExchange.exchange(items, INK, RETURN);
|
||||
if (addedInk > 0) {
|
||||
inkRemaining = addedInk;
|
||||
setChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The output is virtual; inputs are committed only after a result can be given to a player. */
|
||||
@@ -119,23 +109,14 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
|
||||
ItemStack paper = items.get(PAPER);
|
||||
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
|
||||
int cost = WritingInkService.symbolCopyCost();
|
||||
if (inkRemaining >= cost) return true;
|
||||
ItemStack vial = items.get(INK);
|
||||
return vial.is(ModItems.INK_VIAL.get()) && inkRemaining + InkVialItem.remaining(vial) >= cost
|
||||
&& hasBottleSpace();
|
||||
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
|
||||
}
|
||||
|
||||
public boolean consumeCopyMaterials() {
|
||||
if (!canConsumeCopyMaterials()) return false;
|
||||
int cost = WritingInkService.symbolCopyCost();
|
||||
if (inkRemaining < cost) {
|
||||
ItemStack vial = items.get(INK);
|
||||
inkRemaining += Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
|
||||
vial.shrink(1);
|
||||
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
|
||||
ItemStack returned = items.get(RETURN);
|
||||
if (returned.isEmpty()) items.set(RETURN, new ItemStack(Items.GLASS_BOTTLE));
|
||||
else returned.grow(1);
|
||||
inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
|
||||
}
|
||||
inkRemaining -= cost;
|
||||
removeItem(PAPER, 1);
|
||||
@@ -148,7 +129,6 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
|
||||
refillEmptyInkBar();
|
||||
}
|
||||
@Override public void clearContent() {
|
||||
items.clear();
|
||||
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||
inkRemaining = 0;
|
||||
displayPage = ItemStack.EMPTY;
|
||||
|
||||
@@ -13,6 +13,10 @@ import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
|
||||
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.WritingDeskScreen;
|
||||
import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
|
||||
@@ -38,6 +42,24 @@ public final class ClientEvents {
|
||||
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
|
||||
public static void clientSetup(FMLClientSetupEvent event) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.dnorberg.rivencraft.client.screen;
|
||||
|
||||
import java.util.Locale;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
@@ -32,6 +33,7 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
|
||||
private EditBox titleInput;
|
||||
private int firstVisible;
|
||||
private int lastGrammarErrorStripIndex = -1;
|
||||
private boolean stripClickPendingRelease;
|
||||
|
||||
public BookBinderScreen(BookBinderMenu menu, Inventory inventory, Component title) {
|
||||
@@ -86,6 +88,13 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
firstVisible = Math.min(firstVisible, maxFirstVisible());
|
||||
int errorStripIndex = menu.grammarErrorStripIndex();
|
||||
if (errorStripIndex != lastGrammarErrorStripIndex) {
|
||||
if (errorStripIndex >= 0
|
||||
&& (errorStripIndex < firstVisible || errorStripIndex >= firstVisible + VISIBLE_PAGES))
|
||||
firstVisible = Math.clamp(errorStripIndex - VISIBLE_PAGES + 1, 0, maxFirstVisible());
|
||||
lastGrammarErrorStripIndex = errorStripIndex;
|
||||
}
|
||||
graphics.enableScissor(leftPos + STRIP_X + 8, topPos + STRIP_Y,
|
||||
leftPos + STRIP_X + STRIP_WIDTH - 8, topPos + STRIP_Y + STRIP_HEIGHT);
|
||||
int count = visiblePageCount();
|
||||
@@ -97,6 +106,16 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
}
|
||||
graphics.disableScissor();
|
||||
drawScrollEnds(graphics);
|
||||
Component grammarTooltip = menu.grammarError().map(code -> Component.translatable(
|
||||
"screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT),
|
||||
errorStripIndex + 1)).orElse(null);
|
||||
if (menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty()
|
||||
&& mouseX >= leftPos + 152 && mouseX < leftPos + 168
|
||||
&& mouseY >= topPos + 27 && mouseY < topPos + 43
|
||||
&& grammarTooltip != null) {
|
||||
graphics.renderTooltip(font, grammarTooltip, mouseX, mouseY);
|
||||
return;
|
||||
}
|
||||
if (mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
|
||||
if (firstVisible > 0 && mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + 8)
|
||||
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book_binder.previous_pages"), mouseX, mouseY);
|
||||
@@ -111,7 +130,9 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
return;
|
||||
}
|
||||
int hovered = pageIndexAt(mouseX, mouseY);
|
||||
if (hovered >= 0 && hovered < displayedPages() && !pageAtIndex(hovered).isEmpty())
|
||||
if (hovered >= 0 && hovered == errorStripIndex && grammarTooltip != null)
|
||||
graphics.renderTooltip(font, grammarTooltip, mouseX, mouseY);
|
||||
else if (hovered >= 0 && hovered < displayedPages() && !pageAtIndex(hovered).isEmpty())
|
||||
graphics.renderTooltip(font, pageAtIndex(hovered), mouseX, mouseY);
|
||||
else renderTooltip(graphics, mouseX, mouseY);
|
||||
}
|
||||
@@ -146,7 +167,11 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
case BLANK -> { }
|
||||
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000);
|
||||
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);
|
||||
}
|
||||
if (index == menu.grammarErrorStripIndex() && menu.grammarError().isPresent()) {
|
||||
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0x55FF2020);
|
||||
graphics.renderOutline(x, y, PAGE_WIDTH, PAGE_HEIGHT, 0xFFFF3030);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.util.Locale;
|
||||
import net.dnorberg.rivencraft.book.AgeReference;
|
||||
import net.dnorberg.rivencraft.book.AgeTraits;
|
||||
import net.dnorberg.rivencraft.client.ClientBookExploreOverlay;
|
||||
import net.dnorberg.rivencraft.client.SymbolPageTextures;
|
||||
import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload;
|
||||
import net.dnorberg.rivencraft.network.BindDescriptiveBookPayload;
|
||||
import net.dnorberg.rivencraft.network.ModNetworking;
|
||||
@@ -37,7 +38,6 @@ import org.jetbrains.annotations.Nullable;
|
||||
public final class DescriptiveBookScreen extends Screen implements ExplorableBookScreen {
|
||||
private static final int BOOK_TEXT_COLOR = 0xFF3B281B;
|
||||
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_HEIGHT = 199;
|
||||
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.translatable("generator.rivencraft." + generationProfile.getPath())
|
||||
: Component.literal(generationProfile.toString());
|
||||
detailsY = drawWrappedWithoutShadow(graphics,
|
||||
detailsY = drawDescription(graphics,
|
||||
Component.translatable("screen.rivencraft.age.generator", profileName),
|
||||
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) {
|
||||
return drawWrappedWithoutShadow(graphics,
|
||||
return drawDescription(graphics,
|
||||
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) {
|
||||
return Component.translatable("trait.rivencraft." + group + "."
|
||||
+ 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) {
|
||||
Component displayed = pageFree && age != null && pageIndex == 0
|
||||
? text.copy().withStyle(style -> style.withFont(ENCHANTMENT_FONT)) : text;
|
||||
for (FormattedCharSequence line : font.split(displayed, maxWidth)) {
|
||||
for (FormattedCharSequence line : font.split(text, maxWidth)) {
|
||||
graphics.drawString(font, line, x, y, color, false);
|
||||
y += font.lineHeight;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.client.screen;
|
||||
import java.util.Locale;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.client.SymbolPageTextures;
|
||||
import net.dnorberg.rivencraft.menu.NotebookMenu;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
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_COLUMNS = 5;
|
||||
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 boolean draggingScrollbar;
|
||||
private boolean surfaceClickPendingRelease;
|
||||
@@ -262,7 +255,7 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
|
||||
case BLANK -> { }
|
||||
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,
|
||||
28, 28, 16, 16, 96, 96, 128, 128);
|
||||
28, 28, 0, 0, 128, 128, 128, 128);
|
||||
}
|
||||
}
|
||||
if (dimmed) {
|
||||
@@ -274,23 +267,7 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
|
||||
}
|
||||
|
||||
static ResourceLocation symbolTexture(ItemStack page) {
|
||||
var content = PageItem.content(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;
|
||||
};
|
||||
return SymbolPageTextures.forPage(page);
|
||||
}
|
||||
|
||||
private static ResourceLocation texture(String path) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.util.Locale;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.client.SymbolPageTextures;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||
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);
|
||||
graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37);
|
||||
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();
|
||||
if (!collection.isEmpty()) {
|
||||
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);
|
||||
else if (localY >= 177 && localY < 186)
|
||||
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 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,
|
||||
28, 28, 16, 16, 96, 96, 128, 128);
|
||||
28, 28, 0, 0, 128, 128, 128, 128);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
@@ -28,7 +29,7 @@ import net.minecraft.world.phys.AABB;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@GameTestHolder("rivencraft_archive_binder")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class BookBinderGameTests {
|
||||
private BookBinderGameTests() {}
|
||||
@@ -74,7 +75,53 @@ public final class BookBinderGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
public static void missingSymbolPageSurvivesReloadButCannotBeAssembled(GameTestHelper helper) {
|
||||
BookBinderBlockEntity binder = place(helper);
|
||||
binder.setTitle("Recoverable Writing");
|
||||
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||
SymbolReference missing = new SymbolReference(ResourceLocation.parse("example:removed_symbol"));
|
||||
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, PageItem.createRecoveredPage(missing));
|
||||
var saved = binder.saveWithoutMetadata(helper.getLevel().registryAccess());
|
||||
var restored = new BookBinderBlockEntity(binder.getBlockPos(), binder.getBlockState());
|
||||
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
|
||||
helper.assertValueEqual(PageItem.content(restored.getItem(BookBinderBlockEntity.FIRST_PAGE))
|
||||
.symbol().orElseThrow(), missing, "Reload must preserve the removed data-pack symbol ID");
|
||||
restored.getItem(BookBinderBlockEntity.FIRST_PAGE).set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
|
||||
SymbolCatalog.display(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))));
|
||||
helper.assertFalse(restored.canAssemble(),
|
||||
"Forged display metadata cannot make a missing symbol valid for a new Age");
|
||||
helper.assertTrue(restored.getItem(BookBinderBlockEntity.FIRST_PAGE).is(ModItems.PAGE.get()),
|
||||
"Rejected writing must remain recoverable as a Page");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void conflictingGrammarKeepsBinderInputsRecoverable(GameTestHelper helper) {
|
||||
BookBinderBlockEntity binder = place(helper);
|
||||
binder.setTitle("Recoverable Duplicate");
|
||||
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||
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.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");
|
||||
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER),
|
||||
"Rejected grammar must leave the cover recoverable");
|
||||
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()),
|
||||
"Rejected grammar must leave the Link Panel recoverable");
|
||||
helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 5).is(ModItems.PAGE.get()),
|
||||
"Rejected grammar must leave the duplicate page removable");
|
||||
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.succeed();
|
||||
}
|
||||
|
||||
public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) {
|
||||
BookBinderBlockEntity binder = place(helper);
|
||||
binder.setTitle("A New Age");
|
||||
@@ -237,6 +284,38 @@ public final class BookBinderGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
public static void forgedBinderActionsAndReplayedOutputCannotDuplicateBook(GameTestHelper helper) {
|
||||
BookBinderBlockEntity binder = place(helper);
|
||||
binder.setTitle("Single Assembly");
|
||||
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||
var first = helper.makeMockServerPlayerInLevel();
|
||||
var second = helper.makeMockServerPlayerInLevel();
|
||||
for (var player : List.of(first, second)) player.moveTo(binder.getBlockPos().getX() + 0.5,
|
||||
binder.getBlockPos().getY() + 0.5, binder.getBlockPos().getZ() + 0.5);
|
||||
BookBinderMenu firstMenu = new BookBinderMenu(1, first.getInventory(), binder);
|
||||
BookBinderMenu secondMenu = new BookBinderMenu(2, second.getInventory(), binder);
|
||||
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.SELECT_BASE + BookBinderBlockEntity.PAGE_COUNT),
|
||||
"A fabricated page selection must be rejected");
|
||||
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.STRIP_CLICK_BASE
|
||||
+ BookBinderBlockEntity.PAGE_COUNT + 1), "A fabricated strip address must be rejected");
|
||||
helper.assertTrue(firstMenu.quickMoveStack(first, BookBinderMenu.RESULT).is(ModItems.DESCRIPTIVE_BOOK.get()),
|
||||
"The first viewer must receive the single assembled book");
|
||||
helper.assertTrue(secondMenu.quickMoveStack(second, BookBinderMenu.RESULT).isEmpty(),
|
||||
"Another viewer must not take a stale result");
|
||||
helper.assertTrue(firstMenu.quickMoveStack(first, BookBinderMenu.RESULT).isEmpty(),
|
||||
"Replaying the output request must not create a second book");
|
||||
int books = first.getInventory().items.stream().filter(stack -> stack.is(ModItems.DESCRIPTIVE_BOOK.get()))
|
||||
.mapToInt(ItemStack::getCount).sum();
|
||||
helper.assertValueEqual(books, 1, "Only one completed book may be emitted");
|
||||
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).isEmpty(), "Cover must be consumed once");
|
||||
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty(), "Panel must be consumed once");
|
||||
helper.getLevel().setBlock(binder.getBlockPos(), Blocks.AIR.defaultBlockState(), 3);
|
||||
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.IMPORT_NOTEBOOK),
|
||||
"A stale binder menu must reject packets after block removal");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void legacyStripInsertsAndRemovesPagesInOrder(GameTestHelper helper) {
|
||||
BookBinderBlockEntity binder = place(helper);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Small default smoke suite; the detailed milestone regressions are opt-in. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class CoreGameTests {
|
||||
private CoreGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void previewBackendSelection(GameTestHelper helper) {
|
||||
LinkingGameTests.previewBackendSelectionCoversOptionalDependencyMatrix(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void linkTargetPersistence(GameTestHelper helper) {
|
||||
LinkingGameTests.linkTargetSurvivesItemSerialization(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void deterministicAgeDefinition(GameTestHelper helper) {
|
||||
LinkingGameTests.ageDefinitionIsCreatedOnceWithDeterministicData(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void blockedLinkDestination(GameTestHelper helper) {
|
||||
LinkingGameTests.travelRejectsBlockedDestination(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void legacyWritingAndPageFreeBooks(GameTestHelper helper) {
|
||||
WritingDataGameTests.oldBareListWritingAndPageFreeBooksRemainReadable(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void pagePersistenceAndUnknownSymbols(GameTestHelper helper) {
|
||||
WritingDataGameTests.pageItemPreservesContentAndRejectsForgedUnknownSymbols(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void semanticSymbolSchema(GameTestHelper helper) {
|
||||
WritingDataGameTests.semanticSchemaIsServerOnlyBoundedAndV1Compatible(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void orderedWritingGrammar(GameTestHelper helper) {
|
||||
WritingGrammarGameTests.orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void notebookPersistence(GameTestHelper helper) {
|
||||
WritingDataGameTests.notebookContentsPreserveOrderGapsNamesAndUnknownPages(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void deskCopyTransaction(GameTestHelper helper) {
|
||||
WritingDeskGameTests.takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void deskRejectsForgedAndStaleActions(GameTestHelper helper) {
|
||||
WritingDeskGameTests.forgedDeskSelectionsAndStaleMenuCannotCopy(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void twoBlockDeskDropsOnce(GameTestHelper helper) {
|
||||
WritingDeskGameTests.twoBlockDeskSharesOneInventoryAndBreaksTogether(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void oldDeskMigration(GameTestHelper helper) {
|
||||
WritingDeskGameTests.oldDeskInventoryMigratesAndAll88TabsPersist(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void binderKeepsMissingSymbolsRecoverable(GameTestHelper helper) {
|
||||
BookBinderGameTests.missingSymbolPageSurvivesReloadButCannotBeAssembled(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void binderAssemblyTransaction(GameTestHelper helper) {
|
||||
BookBinderGameTests.assemblyIsAtomicAndRetainsPageOrder(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void binderRejectsConflictingGrammar(GameTestHelper helper) {
|
||||
BookBinderGameTests.conflictingGrammarKeepsBinderInputsRecoverable(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void binderRejectsForgedAndReplayedActions(GameTestHelper helper) {
|
||||
BookBinderGameTests.forgedBinderActionsAndReplayedOutputCannotDuplicateBook(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void mixerOutputTransaction(GameTestHelper helper) {
|
||||
InkMixerGameTests.linkPanelRequiresTakingOutputAndPersistsInk(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void linkingBookRecipe(GameTestHelper helper) {
|
||||
LinkingBookRecipeGameTests.legacyRecipeRequiresExactlyLeatherAndLinkPanel(helper);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
@@ -12,11 +11,13 @@ import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.world.inventory.ClickType;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@GameTestHolder("rivencraft_archive_mixer")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class InkMixerGameTests {
|
||||
private InkMixerGameTests() {}
|
||||
@@ -27,7 +28,6 @@ public final class InkMixerGameTests {
|
||||
return (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void linkPanelRequiresTakingOutputAndPersistsInk(GameTestHelper helper) {
|
||||
InkMixerBlockEntity mixer = place(helper);
|
||||
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
|
||||
@@ -98,6 +98,14 @@ public final class InkMixerGameTests {
|
||||
"Shift-click must consume only one paper");
|
||||
helper.getLevel().setBlock(mixer.getBlockPos(), Blocks.AIR.defaultBlockState(), 3);
|
||||
helper.assertFalse(menu.stillValid(player), "Breaking the mixer must invalidate its menu");
|
||||
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class,
|
||||
new AABB(mixer.getBlockPos()).inflate(2));
|
||||
int paper = drops.stream().filter(entity -> entity.getItem().is(Items.PAPER))
|
||||
.mapToInt(entity -> entity.getItem().getCount()).sum();
|
||||
int bottles = drops.stream().filter(entity -> entity.getItem().is(Items.GLASS_BOTTLE))
|
||||
.mapToInt(entity -> entity.getItem().getCount()).sum();
|
||||
helper.assertValueEqual(paper, 1, "Breaking the mixer must return unused paper once");
|
||||
helper.assertValueEqual(bottles, 1, "Breaking the mixer must return the consumed vial's bottle once");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,11 @@ import net.minecraft.world.item.crafting.RecipeType;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@GameTestHolder("rivencraft_archive_recipe")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class LinkingBookRecipeGameTests {
|
||||
private LinkingBookRecipeGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void legacyRecipeRequiresExactlyLeatherAndLinkPanel(GameTestHelper helper) {
|
||||
LinkingBookRecipe recipe = new LinkingBookRecipe(CraftingBookCategory.MISC);
|
||||
ItemStack panel = PageItem.createLinkPanelPage();
|
||||
|
||||
@@ -34,7 +34,7 @@ import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Server-side regression coverage for persistent linking-book data and travel safety. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@GameTestHolder("rivencraft_archive_linking")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class LinkingGameTests {
|
||||
private static final BlockPos ORIGIN = new BlockPos(0, 1, 0);
|
||||
@@ -42,7 +42,6 @@ public final class LinkingGameTests {
|
||||
private LinkingGameTests() {
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void previewBackendSelectionCoversOptionalDependencyMatrix(GameTestHelper helper) {
|
||||
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, true,
|
||||
PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
|
||||
@@ -127,7 +126,6 @@ public final class LinkingGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void linkTargetSurvivesItemSerialization(GameTestHelper helper) {
|
||||
LinkTarget expected = new LinkTarget(
|
||||
GlobalPos.of(helper.getLevel().dimension(), helper.absolutePos(ORIGIN)),
|
||||
@@ -181,7 +179,6 @@ public final class LinkingGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void ageDefinitionIsCreatedOnceWithDeterministicData(GameTestHelper helper) {
|
||||
UUID ageId = UUID.randomUUID();
|
||||
String requestedName = "Test Age " + ageId;
|
||||
@@ -666,7 +663,6 @@ public final class LinkingGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void travelRejectsBlockedDestination(GameTestHelper helper) {
|
||||
BlockPos absoluteOrigin = helper.absolutePos(ORIGIN);
|
||||
for (BlockPos position : BlockPos.betweenClosed(
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.book.AgeReference;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
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.WritingEntry;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
@@ -29,7 +36,7 @@ import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Acceptance coverage for Milestone 4's persistent writing contracts. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@GameTestHolder("rivencraft_archive_writing")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class WritingDataGameTests {
|
||||
private WritingDataGameTests() {
|
||||
@@ -60,7 +67,6 @@ public final class WritingDataGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void oldBareListWritingAndPageFreeBooksRemainReadable(GameTestHelper helper) {
|
||||
OrderedWriting writing = new OrderedWriting(List.of(
|
||||
WritingEntry.linkPanel(), WritingEntry.symbol(reference("example:removed_symbol"))));
|
||||
@@ -71,8 +77,12 @@ public final class WritingDataGameTests {
|
||||
"0.3.7.2 bare-list writing must load into schema version 1 intact");
|
||||
|
||||
ItemStack oldPageFreeBook = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
|
||||
AgeReference originalAge = new AgeReference(UUID.randomUUID(), "Legacy Page-Free Age");
|
||||
oldPageFreeBook.set(ModDataComponents.AGE_REFERENCE.get(), originalAge);
|
||||
CompoundTag saved = (CompoundTag) oldPageFreeBook.saveOptional(helper.getLevel().registryAccess());
|
||||
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), saved);
|
||||
helper.assertValueEqual(restored.get(ModDataComponents.AGE_REFERENCE.get()), originalAge,
|
||||
"A page-free book must keep its existing Age identity");
|
||||
helper.assertValueEqual(restored.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY),
|
||||
OrderedWriting.EMPTY, "Older page-free books must not require writing migration");
|
||||
helper.succeed();
|
||||
@@ -96,6 +106,80 @@ public final class WritingDataGameTests {
|
||||
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")
|
||||
public static void pageStatesPersistAndNetworkRoundTrip(GameTestHelper helper) {
|
||||
SymbolReference known = reference("rivencraft:terrain/standard");
|
||||
@@ -120,7 +204,6 @@ public final class WritingDataGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void pageItemPreservesContentAndRejectsForgedUnknownSymbols(GameTestHelper helper) {
|
||||
SymbolReference known = reference("rivencraft:terrain/standard");
|
||||
SymbolReference unknown = reference("example:forged_symbol");
|
||||
@@ -136,6 +219,10 @@ public final class WritingDataGameTests {
|
||||
);
|
||||
|
||||
ItemStack recovered = PageItem.createRecoveredPage(unknown);
|
||||
CompoundTag recoveredTag = (CompoundTag) recovered.saveOptional(helper.getLevel().registryAccess());
|
||||
ItemStack restoredRecovered = ItemStack.parseOptional(helper.getLevel().registryAccess(), recoveredTag);
|
||||
helper.assertValueEqual(PageItem.content(restoredRecovered).symbol().orElseThrow(), unknown,
|
||||
"A missing symbol must survive a full Page item reload");
|
||||
helper.assertFalse(PageItem.isUsableWriting(recovered),
|
||||
"An unresolved page must remain recoverable without becoming valid writing");
|
||||
try {
|
||||
@@ -146,7 +233,6 @@ public final class WritingDataGameTests {
|
||||
}
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) {
|
||||
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
|
||||
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.WritingDeskBlock;
|
||||
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||
@@ -26,7 +25,7 @@ import net.minecraft.world.phys.AABB;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@GameTestHolder("rivencraft_archive_desk")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class WritingDeskGameTests {
|
||||
private WritingDeskGameTests() {}
|
||||
@@ -94,7 +93,6 @@ public final class WritingDeskGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||
@@ -249,11 +247,39 @@ public final class WritingDeskGameTests {
|
||||
"First viewer should take the last available copy");
|
||||
helper.assertTrue(secondMenu.quickMoveStack(second, WritingDeskMenu.COPY_RESULT).isEmpty(),
|
||||
"Second viewer must not copy the already-consumed paper");
|
||||
helper.assertTrue(firstMenu.quickMoveStack(first, WritingDeskMenu.COPY_RESULT).isEmpty(),
|
||||
"Replaying the first viewer's output request must not make another page");
|
||||
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
|
||||
"Shared desk must spend paper only once");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
public static void forgedDeskSelectionsAndStaleMenuCannotCopy(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||
desk.setItem(0, PageItem.createLinkPanelPage());
|
||||
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||
var player = helper.makeMockServerPlayerInLevel();
|
||||
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.CATALOG_BUTTON_BASE + 999),
|
||||
"A fabricated catalog index must be rejected");
|
||||
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + 27),
|
||||
"A fabricated page address must be rejected");
|
||||
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 0,
|
||||
WritingDeskBlockEntity.PAPER)), "A page cannot be extracted into a material slot");
|
||||
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
|
||||
"Forged actions must not spend paper");
|
||||
helper.assertTrue(desk.getItem(0).is(ModItems.PAGE.get()), "Forged actions must not remove the source");
|
||||
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||
helper.assertFalse(menu.clickMenuButton(player, 0), "A stale menu must reject selection packets");
|
||||
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
|
||||
"A stale menu must reject output packets");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void catalogAggregatesKnownSymbolsAcrossCollections(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
@@ -310,7 +336,6 @@ public final class WritingDeskGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void twoBlockDeskSharesOneInventoryAndBreaksTogether(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
var block = ModBlocks.WRITING_DESK.get();
|
||||
@@ -433,7 +458,6 @@ public final class WritingDeskGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void oldDeskInventoryMigratesAndAll88TabsPersist(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
var registries = helper.getLevel().registryAccess();
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolDefinition;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Issue #30: parsing must not mutate writing or trust client display metadata. */
|
||||
@GameTestHolder("rivencraft_archive_grammar")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class WritingGrammarGameTests {
|
||||
private WritingGrammarGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences(GameTestHelper helper) {
|
||||
SymbolReference terrain = ref("rivencraft:terrain/standard");
|
||||
SymbolReference nativeBiomes = ref("rivencraft:biome/native");
|
||||
SymbolReference weather = ref("rivencraft:weather/normal");
|
||||
SymbolReference lighting = ref("rivencraft:lighting/normal");
|
||||
SymbolReference celestial = ref("rivencraft:celestial/normal_time");
|
||||
SymbolReference caves = ref("rivencraft:feature/caves");
|
||||
SymbolReference plains = ref("test:biome/plains");
|
||||
SymbolReference forest = ref("test:biome/forest");
|
||||
SymbolReference single = ref("test:controller/single");
|
||||
SymbolReference alternateTerrain = ref("test:terrain/alternate");
|
||||
SymbolReference periodHalf = ref("test:period/half");
|
||||
SymbolReference periodFull = ref("test:period/full");
|
||||
SymbolReference displayOnly = ref("test:display_only");
|
||||
Map<SymbolReference, SymbolDefinition> definitions = Map.of(
|
||||
plains, definition(SymbolSemantics.Role.BIOME,
|
||||
new SymbolSemantics.ResourceValue(ResourceLocation.parse("minecraft:plains"))),
|
||||
forest, definition(SymbolSemantics.Role.BIOME,
|
||||
new SymbolSemantics.ResourceValue(ResourceLocation.parse("minecraft:forest"))),
|
||||
single, definition(SymbolSemantics.Role.BIOME_CONTROLLER,
|
||||
new SymbolSemantics.ChoiceValue("single")),
|
||||
alternateTerrain, definition(SymbolSemantics.Role.TERRAIN,
|
||||
new SymbolSemantics.ResourceValue(ResourceLocation.parse("test:alternate"))),
|
||||
periodHalf, definition(SymbolSemantics.Role.PERIOD_MODIFIER,
|
||||
new SymbolSemantics.NumberValue(0.5)),
|
||||
periodFull, definition(SymbolSemantics.Role.PERIOD_MODIFIER,
|
||||
new SymbolSemantics.NumberValue(1.0)),
|
||||
displayOnly, new SymbolDefinition(1, "symbol.test", "symbol.test.description",
|
||||
ResourceLocation.parse("test:display"), Optional.empty(), ""));
|
||||
java.util.function.Function<SymbolReference, Optional<SymbolDefinition>> resolver = reference ->
|
||||
Optional.ofNullable(definitions.get(reference)).or(() -> SymbolCatalog.find(reference));
|
||||
|
||||
helper.assertTrue(parse(resolver, WritingEntry.linkPanel()).randomFallback(),
|
||||
"Link-Panel-only writing must retain the random Age path");
|
||||
helper.assertTrue(WritingGrammar.parse(OrderedWriting.EMPTY, resolver).randomFallback(),
|
||||
"Old page-free books must retain the random Age path");
|
||||
OrderedWriting partial = writing(WritingEntry.linkPanel(), WritingEntry.symbol(terrain));
|
||||
WritingGrammar.Result incomplete = WritingGrammar.parse(partial, resolver);
|
||||
helper.assertTrue(incomplete.canCreate() && !incomplete.randomFallback(),
|
||||
"Partial authored writing must remain valid, without pretending it is page-free");
|
||||
helper.assertTrue(incomplete.diagnostics().stream().anyMatch(d -> d.code() == WritingGrammar.Code.INCOMPLETE),
|
||||
"Missing branches must be reported for deterministic completion");
|
||||
helper.assertValueEqual(partial.entries().size(), 2, "Parsing must not rewrite saved page order");
|
||||
|
||||
WritingGrammar.Result complete = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
|
||||
WritingEntry.symbol(plains), WritingEntry.symbol(single), WritingEntry.symbol(weather),
|
||||
WritingEntry.symbol(lighting), WritingEntry.symbol(periodHalf), WritingEntry.symbol(celestial),
|
||||
WritingEntry.symbol(caves));
|
||||
helper.assertTrue(complete.canCreate(), "Legacy-ordered categories and prefix modifiers must parse");
|
||||
helper.assertValueEqual(complete.symbols().size(), 8, "Parsed symbols must preserve every authored page");
|
||||
helper.assertValueEqual(complete.symbols().get(1).reference(), plains,
|
||||
"A biome page must precede the controller that consumes it");
|
||||
helper.assertValueEqual(WritingGrammar.parse(writing(WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
|
||||
WritingEntry.symbol(plains), WritingEntry.symbol(single), WritingEntry.symbol(weather),
|
||||
WritingEntry.symbol(lighting), WritingEntry.symbol(periodHalf), WritingEntry.symbol(celestial),
|
||||
WritingEntry.symbol(caves)), resolver), complete,
|
||||
"Identical ordered writing and server definitions must parse deterministically");
|
||||
|
||||
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),
|
||||
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),
|
||||
WritingEntry.symbol(terrain)), WritingGrammar.Code.OUT_OF_ORDER);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial),
|
||||
WritingEntry.symbol(periodHalf)), WritingGrammar.Code.MISPLACED_MODIFIER);
|
||||
assertCode(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),
|
||||
WritingEntry.symbol(nativeBiomes)), WritingGrammar.Code.CONFLICT);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains),
|
||||
WritingEntry.symbol(forest), WritingEntry.symbol(single)), WritingGrammar.Code.CONFLICT);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(caves),
|
||||
WritingEntry.symbol(caves)), WritingGrammar.Code.DUPLICATE);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(ref("test:missing"))),
|
||||
WritingGrammar.Code.UNRESOLVED);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(displayOnly)),
|
||||
WritingGrammar.Code.UNSUPPORTED);
|
||||
assertCode(helper, parse(resolver, WritingEntry.symbol(terrain)), WritingGrammar.Code.MISSING_PANEL);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.linkPanel()),
|
||||
WritingGrammar.Code.DUPLICATE_PANEL);
|
||||
|
||||
List<WritingEntry> oversized = new ArrayList<>();
|
||||
for (int index = 0; index <= WritingGrammar.MAX_NEW_ENTRIES; index++)
|
||||
oversized.add(WritingEntry.linkPanel());
|
||||
assertCode(helper, WritingGrammar.parse(new OrderedWriting(oversized), resolver), WritingGrammar.Code.TOO_MANY);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static SymbolReference ref(String id) {
|
||||
return new SymbolReference(ResourceLocation.parse(id));
|
||||
}
|
||||
|
||||
private static SymbolDefinition definition(SymbolSemantics.Role role, SymbolSemantics.Value value) {
|
||||
return new SymbolDefinition(2, "symbol.test", "symbol.test.description",
|
||||
ResourceLocation.parse("test:" + role.id()), Optional.of(new SymbolSemantics(role, value)), "");
|
||||
}
|
||||
|
||||
private static OrderedWriting writing(WritingEntry... entries) {
|
||||
return new OrderedWriting(List.of(entries));
|
||||
}
|
||||
|
||||
private static WritingGrammar.Result parse(
|
||||
java.util.function.Function<SymbolReference, Optional<SymbolDefinition>> resolver,
|
||||
WritingEntry... entries) {
|
||||
return WritingGrammar.parse(writing(entries), resolver);
|
||||
}
|
||||
|
||||
private static void assertCode(GameTestHelper helper, WritingGrammar.Result result, WritingGrammar.Code code) {
|
||||
helper.assertFalse(result.canCreate(), "Writing with " + code + " must not create a new Age");
|
||||
helper.assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.code() == code),
|
||||
"Writing must report " + code + " rather than silently dropping a page");
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.SymbolPoems;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.item.component.CustomModelData;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
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(
|
||||
reference.id(), key, key + ".description",
|
||||
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path.split("/")[0]),
|
||||
true));
|
||||
true, SymbolPoems.builtIn(reference.id())));
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,6 @@ public final class PageItem extends Item {
|
||||
private static ItemStack create(PageContent content) {
|
||||
ItemStack stack = new ItemStack(ModItems.PAGE.get());
|
||||
stack.set(ModDataComponents.PAGE_CONTENT.get(), content);
|
||||
stack.set(DataComponents.CUSTOM_MODEL_DATA, new CustomModelData(modelIndex(content)));
|
||||
if (content.kind() == PageContent.Kind.SYMBOL) {
|
||||
stack.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
|
||||
SymbolCatalog.display(content.symbol().orElseThrow()));
|
||||
@@ -134,20 +133,4 @@ public final class PageItem extends Item {
|
||||
: 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import java.util.Optional;
|
||||
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
@@ -32,6 +34,8 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
private final SimpleContainer result = new SimpleContainer(1);
|
||||
private final boolean clientSide;
|
||||
private int selectedPage = -1;
|
||||
private int grammarErrorCode = -1;
|
||||
private int grammarErrorStripIndex = -1;
|
||||
|
||||
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
|
||||
@@ -47,6 +51,20 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
@Override public int get() { return selectedPage; }
|
||||
@Override public void set(int value) { selectedPage = value; }
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() {
|
||||
return clientSide || binder == null ? grammarErrorCode : binder.writingGrammar().firstError()
|
||||
.map(error -> error.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);
|
||||
}
|
||||
@Override public void set(int value) { grammarErrorStripIndex = value; }
|
||||
});
|
||||
addSlot(new BinderSlot(BookBinderBlockEntity.COVER, 8, 27, 64));
|
||||
addSlot(new BinderSlot(BookBinderBlockEntity.LINK_PANEL, -1000, -1000, 1));
|
||||
addSlot(new BinderSlot(BookBinderBlockEntity.NOTEBOOK, -1000, -1000, 1));
|
||||
@@ -66,6 +84,12 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
}
|
||||
|
||||
public int selectedPage() { return selectedPage; }
|
||||
public Optional<WritingGrammar.Code> grammarError() {
|
||||
WritingGrammar.Code[] codes = WritingGrammar.Code.values();
|
||||
return grammarErrorCode >= 0 && grammarErrorCode < codes.length
|
||||
? Optional.of(codes[grammarErrorCode]) : Optional.empty();
|
||||
}
|
||||
public int grammarErrorStripIndex() { return grammarErrorStripIndex; }
|
||||
public String title() { return binder == null ? "" : binder.title(); }
|
||||
public boolean setTitle(Player player, String title) {
|
||||
if (clientSide || !stillValid(player) || title == null
|
||||
|
||||
@@ -166,15 +166,12 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
|
||||
selectedPage = page;
|
||||
selectedSymbol = null;
|
||||
} else if (id >= CATALOG_BUTTON_BASE && id < CATALOG_BUTTON_BASE + catalog.size()) {
|
||||
if (!catalog.equals(WritingDeskCatalog.available(blockEntity))) {
|
||||
refreshCatalog();
|
||||
List<SymbolDisplayMetadata> available = WritingDeskCatalog.available(blockEntity);
|
||||
if (!catalog.equals(available)) {
|
||||
refreshCatalog(available);
|
||||
return false;
|
||||
}
|
||||
ResourceLocation requested = catalog.get(id - CATALOG_BUTTON_BASE).id();
|
||||
if (!WritingDeskCatalog.available(blockEntity).stream().anyMatch(entry -> entry.id().equals(requested))) {
|
||||
return false;
|
||||
}
|
||||
selectedSymbol = requested;
|
||||
selectedSymbol = available.get(id - CATALOG_BUTTON_BASE).id();
|
||||
} else return false;
|
||||
refreshOutput();
|
||||
return true;
|
||||
@@ -390,7 +387,11 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
|
||||
|
||||
private void refreshCatalog() {
|
||||
if (clientSide || blockEntity == null) return;
|
||||
List<SymbolDisplayMetadata> current = WritingDeskCatalog.available(blockEntity);
|
||||
refreshCatalog(WritingDeskCatalog.available(blockEntity));
|
||||
}
|
||||
|
||||
private void refreshCatalog(List<SymbolDisplayMetadata> current) {
|
||||
if (clientSide || blockEntity == null) return;
|
||||
if (!current.equals(catalog)) {
|
||||
catalog = current;
|
||||
if (serverViewer != null && serverViewer.containerMenu == this) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.GlobalPos;
|
||||
@@ -133,6 +134,14 @@ public final class ModNetworking {
|
||||
return;
|
||||
}
|
||||
|
||||
var grammar = WritingGrammar.parse(access.stack()
|
||||
.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY));
|
||||
if (!grammar.canCreate()) {
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.invalid_writing",
|
||||
grammar.firstError().orElseThrow().message()), true);
|
||||
return;
|
||||
}
|
||||
|
||||
WRITING_BOOKS.add(access.stack());
|
||||
try {
|
||||
AgeReference reference = new AgeReference(UUID.randomUUID(), title);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Shared, server-side vial-to-meter exchange for the Desk and Ink Mixer. */
|
||||
public final class InkVialExchange {
|
||||
private InkVialExchange() {}
|
||||
|
||||
public static boolean canPay(NonNullList<ItemStack> items, int inkSlot, int bottleSlot,
|
||||
int storedInk, int cost) {
|
||||
return storedInk >= cost || canExchange(items, inkSlot, bottleSlot)
|
||||
&& storedInk + vialInk(items.get(inkSlot)) >= cost;
|
||||
}
|
||||
|
||||
/** Returns added ink; zero means no inputs were changed. */
|
||||
public static int exchange(NonNullList<ItemStack> items, int inkSlot, int bottleSlot) {
|
||||
if (!canExchange(items, inkSlot, bottleSlot)) return 0;
|
||||
ItemStack vial = items.get(inkSlot);
|
||||
int addedInk = vialInk(vial);
|
||||
vial.shrink(1);
|
||||
if (vial.isEmpty()) items.set(inkSlot, ItemStack.EMPTY);
|
||||
ItemStack bottles = items.get(bottleSlot);
|
||||
if (bottles.isEmpty()) items.set(bottleSlot, new ItemStack(Items.GLASS_BOTTLE));
|
||||
else bottles.grow(1);
|
||||
return addedInk;
|
||||
}
|
||||
|
||||
private static boolean canExchange(NonNullList<ItemStack> items, int inkSlot, int bottleSlot) {
|
||||
ItemStack vial = items.get(inkSlot);
|
||||
ItemStack bottles = items.get(bottleSlot);
|
||||
return vial.is(ModItems.INK_VIAL.get()) && vialInk(vial) > 0
|
||||
&& (bottles.isEmpty() || bottles.is(Items.GLASS_BOTTLE)
|
||||
&& bottles.getCount() < bottles.getMaxStackSize());
|
||||
}
|
||||
|
||||
private static int vialInk(ItemStack vial) {
|
||||
return Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package net.dnorberg.rivencraft.writing;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -56,10 +55,13 @@ public final class SymbolCatalog {
|
||||
ResourceManager resourceManager,
|
||||
ProfilerFiller profiler) {
|
||||
Map<ResourceLocation, SymbolDefinition> loaded = new HashMap<>();
|
||||
resources.forEach((id, json) -> SymbolDefinition.CODEC.parse(JsonOps.INSTANCE, json)
|
||||
.resultOrPartial(error -> Rivencraft.LOGGER.error(
|
||||
"Ignoring invalid writing symbol {}: {}", id, error))
|
||||
.ifPresent(definition -> loaded.put(id, definition)));
|
||||
resources.forEach((id, json) -> {
|
||||
try {
|
||||
loaded.put(id, SymbolDefinition.parse(json));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
Rivencraft.LOGGER.error("Ignoring invalid writing symbol {}: {}", id, exception.getMessage());
|
||||
}
|
||||
});
|
||||
symbols = Map.copyOf(loaded);
|
||||
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
|
||||
}
|
||||
|
||||
@@ -1,47 +1,127 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.DataResult;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
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(
|
||||
int schemaVersion,
|
||||
String nameTranslationKey,
|
||||
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 Codec<String> TRANSLATION_KEY_CODEC = Codec.STRING.comapFlatMap(
|
||||
value -> TRANSLATION_KEY.matcher(value).matches()
|
||||
? 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);
|
||||
private static final Set<String> V2_FIELDS = Set.of(
|
||||
"schema_version", "name", "description", "category", "semantics", "poem");
|
||||
|
||||
public SymbolDefinition {
|
||||
Objects.requireNonNull(nameTranslationKey, "nameTranslationKey");
|
||||
Objects.requireNonNull(descriptionTranslationKey, "descriptionTranslationKey");
|
||||
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) {
|
||||
return new SymbolDisplayMetadata(id, nameTranslationKey, descriptionTranslationKey, category, true);
|
||||
return new SymbolDisplayMetadata(id, nameTranslationKey, descriptionTranslationKey, category, true, poem);
|
||||
}
|
||||
|
||||
private static DataResult<SymbolDefinition> validate(SymbolDefinition definition) {
|
||||
return definition.schemaVersion == CURRENT_SCHEMA_VERSION
|
||||
? DataResult.success(definition)
|
||||
: DataResult.error(() -> "Unsupported symbol schema_version " + definition.schemaVersion);
|
||||
static String requiredString(JsonObject object, String field) {
|
||||
JsonElement element = object.get(field);
|
||||
if (element == null || !element.isJsonPrimitive()
|
||||
|| !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 descriptionTranslationKey,
|
||||
ResourceLocation category,
|
||||
boolean resolved
|
||||
boolean resolved,
|
||||
String poem
|
||||
) {
|
||||
public static final Codec<SymbolDisplayMetadata> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
ResourceLocation.CODEC.fieldOf("id").forGetter(SymbolDisplayMetadata::id),
|
||||
Codec.STRING.fieldOf("name").forGetter(SymbolDisplayMetadata::nameTranslationKey),
|
||||
Codec.STRING.fieldOf("description").forGetter(SymbolDisplayMetadata::descriptionTranslationKey),
|
||||
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));
|
||||
public static final StreamCodec<ByteBuf, SymbolDisplayMetadata> STREAM_CODEC = StreamCodec.composite(
|
||||
ResourceLocation.STREAM_CODEC, SymbolDisplayMetadata::id,
|
||||
@@ -29,6 +31,7 @@ public record SymbolDisplayMetadata(
|
||||
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::descriptionTranslationKey,
|
||||
ResourceLocation.STREAM_CODEC, SymbolDisplayMetadata::category,
|
||||
ByteBufCodecs.BOOL, SymbolDisplayMetadata::resolved,
|
||||
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::poem,
|
||||
SymbolDisplayMetadata::new
|
||||
);
|
||||
|
||||
@@ -38,7 +41,8 @@ public record SymbolDisplayMetadata(
|
||||
"symbol.rivencraft.unresolved",
|
||||
"symbol.rivencraft.unresolved.description",
|
||||
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";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
@@ -20,7 +20,7 @@ public final class WritingDeskCatalog {
|
||||
private WritingDeskCatalog() {}
|
||||
|
||||
public static List<SymbolDisplayMetadata> available(WritingDeskBlockEntity desk) {
|
||||
Map<ResourceLocation, SymbolDisplayMetadata> unique = new TreeMap<>();
|
||||
Map<ResourceLocation, SymbolDisplayMetadata> unique = new HashMap<>();
|
||||
for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) {
|
||||
ItemStack collection = desk.getItem(slot);
|
||||
if (collection.is(ModItems.PAGE.get())) add(unique, collection);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics.ChoiceValue;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics.Role;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** Server-owned interpretation of ordered writing; compilation belongs to the next release slice. */
|
||||
public final class WritingGrammar {
|
||||
// A newly assembled book has one Link Panel plus at most 27 page positions.
|
||||
public static final int MAX_NEW_ENTRIES = NotebookContents.MAX_PAGES + 1;
|
||||
|
||||
private WritingGrammar() {}
|
||||
|
||||
public static Result parse(OrderedWriting writing) {
|
||||
return parse(writing, SymbolCatalog::find);
|
||||
}
|
||||
|
||||
/** The resolver must be the authoritative server catalog, never page display metadata. */
|
||||
public static Result parse(OrderedWriting writing,
|
||||
Function<SymbolReference, Optional<SymbolDefinition>> resolver) {
|
||||
Objects.requireNonNull(writing, "writing");
|
||||
Objects.requireNonNull(resolver, "resolver");
|
||||
List<ParsedSymbol> symbols = new ArrayList<>();
|
||||
List<Diagnostic> diagnostics = new ArrayList<>();
|
||||
if (writing.entries().size() > MAX_NEW_ENTRIES) {
|
||||
diagnostics.add(new Diagnostic(Code.TOO_MANY, -1, "At most " + MAX_NEW_ENTRIES + " entries are allowed"));
|
||||
return new Result(symbols, diagnostics);
|
||||
}
|
||||
if (writing.entries().isEmpty()) return new Result(symbols, diagnostics);
|
||||
|
||||
EnumMap<Role, ParsedSymbol> singular = new EnumMap<>(Role.class);
|
||||
Set<ResourceLocation> features = new HashSet<>();
|
||||
int panels = 0;
|
||||
int lastStage = -1;
|
||||
int biomeCount = 0;
|
||||
for (int index = 0; index < writing.entries().size(); index++) {
|
||||
WritingEntry entry = writing.entries().get(index);
|
||||
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
|
||||
panels++;
|
||||
if (panels > 1) diagnostics.add(new Diagnostic(Code.DUPLICATE_PANEL, index,
|
||||
"Use exactly one Link Panel"));
|
||||
else if (index != 0) diagnostics.add(new Diagnostic(Code.PANEL_ORDER, index,
|
||||
"Move the Link Panel to the first position"));
|
||||
continue;
|
||||
}
|
||||
|
||||
SymbolReference reference = entry.symbol().orElseThrow();
|
||||
Optional<SymbolDefinition> definition = resolver.apply(reference);
|
||||
if (definition.isEmpty()) {
|
||||
diagnostics.add(new Diagnostic(Code.UNRESOLVED, index,
|
||||
"Restore the data pack for " + reference.id() + " or remove this page"));
|
||||
continue;
|
||||
}
|
||||
Optional<SymbolSemantics> semantics = definition.orElseThrow().semantics();
|
||||
if (semantics.isEmpty()) {
|
||||
diagnostics.add(new Diagnostic(Code.UNSUPPORTED, index,
|
||||
"Update " + reference.id() + " to semantic schema v2 or remove this page"));
|
||||
continue;
|
||||
}
|
||||
|
||||
Role role = semantics.orElseThrow().role();
|
||||
ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow());
|
||||
symbols.add(parsed);
|
||||
if (singular(role)) {
|
||||
ParsedSymbol previous = singular.putIfAbsent(role, parsed);
|
||||
if (previous != null) {
|
||||
Code code = previous.semantics().value().equals(parsed.semantics().value())
|
||||
? Code.DUPLICATE : Code.CONFLICT;
|
||||
diagnostics.add(new Diagnostic(code, index,
|
||||
"Remove this " + role.id() + " page; position " + (previous.index() + 1)
|
||||
+ " already supplies that category"));
|
||||
continue;
|
||||
}
|
||||
} else if (role == Role.FEATURE && !features.add(reference.id())) {
|
||||
diagnostics.add(new Diagnostic(Code.DUPLICATE, index,
|
||||
"Remove the repeated feature page " + reference.id()));
|
||||
continue;
|
||||
}
|
||||
if (role == Role.BIOME) biomeCount++;
|
||||
int stage = stage(role);
|
||||
if (stage < lastStage) {
|
||||
diagnostics.add(new Diagnostic(modifier(role) ? Code.MISPLACED_MODIFIER : Code.OUT_OF_ORDER,
|
||||
index, "Move this " + role.id() + " page before later grammar categories"));
|
||||
} else {
|
||||
lastStage = stage;
|
||||
}
|
||||
}
|
||||
if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1,
|
||||
"Add a Link Panel as the first page"));
|
||||
if (symbols.isEmpty()) return new Result(symbols, diagnostics);
|
||||
|
||||
ParsedSymbol controller = singular.get(Role.BIOME_CONTROLLER);
|
||||
if (controller != null && controller.semantics().value() instanceof ChoiceValue choice
|
||||
&& choice.id().equals("native") && biomeCount > 0) {
|
||||
diagnostics.add(new Diagnostic(Code.CONFLICT, controller.index(),
|
||||
"Native biome distribution cannot use authored biome pages"));
|
||||
} else if (controller != null && controller.semantics().value() instanceof ChoiceValue choice
|
||||
&& choice.id().equals("single") && biomeCount > 1) {
|
||||
diagnostics.add(new Diagnostic(Code.CONFLICT, controller.index(),
|
||||
"Single-biome distribution accepts only one authored biome page"));
|
||||
}
|
||||
for (Role required : List.of(Role.TERRAIN, Role.BIOME_CONTROLLER, Role.WEATHER,
|
||||
Role.LIGHTING, Role.CELESTIAL)) {
|
||||
if (!singular.containsKey(required)) diagnostics.add(new Diagnostic(Code.INCOMPLETE, -1,
|
||||
"The Age compiler will fill the missing " + required.id() + " branch from the Age seed"));
|
||||
}
|
||||
if (controller != null && controller.semantics().value() instanceof ChoiceValue choice) {
|
||||
int minimum = switch (choice.id()) {
|
||||
case "single" -> 1;
|
||||
case "tiled", "grid" -> 2;
|
||||
case "native" -> 0;
|
||||
default -> 3;
|
||||
};
|
||||
if (biomeCount < minimum) diagnostics.add(new Diagnostic(Code.INCOMPLETE, -1,
|
||||
"The Age compiler will fill " + (minimum - biomeCount) + " missing biome page(s)"));
|
||||
}
|
||||
return new Result(symbols, diagnostics);
|
||||
}
|
||||
|
||||
private static boolean singular(Role role) {
|
||||
return switch (role) {
|
||||
case TERRAIN, BIOME_CONTROLLER, WEATHER, LIGHTING, CELESTIAL -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean modifier(Role role) {
|
||||
return role == Role.PERIOD_MODIFIER || role == Role.ANGLE_MODIFIER || role == Role.PHASE_MODIFIER;
|
||||
}
|
||||
|
||||
private static int stage(Role role) {
|
||||
return switch (role) {
|
||||
case TERRAIN -> 0;
|
||||
case BIOME -> 1;
|
||||
case BIOME_CONTROLLER -> 2;
|
||||
case WEATHER -> 3;
|
||||
case LIGHTING -> 4;
|
||||
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER -> 5;
|
||||
case CELESTIAL -> 6;
|
||||
case FEATURE -> 7;
|
||||
};
|
||||
}
|
||||
|
||||
public enum Code {
|
||||
TOO_MANY, MISSING_PANEL, PANEL_ORDER, DUPLICATE_PANEL, UNRESOLVED, UNSUPPORTED,
|
||||
DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, INCOMPLETE
|
||||
}
|
||||
|
||||
public record Diagnostic(Code code, int index, String message) {
|
||||
public Diagnostic {
|
||||
Objects.requireNonNull(code, "code");
|
||||
Objects.requireNonNull(message, "message");
|
||||
}
|
||||
|
||||
public boolean error() { return code != Code.INCOMPLETE; }
|
||||
}
|
||||
|
||||
public record ParsedSymbol(int index, SymbolReference reference, SymbolSemantics semantics) {
|
||||
public ParsedSymbol {
|
||||
Objects.requireNonNull(reference, "reference");
|
||||
Objects.requireNonNull(semantics, "semantics");
|
||||
}
|
||||
}
|
||||
|
||||
public record Result(List<ParsedSymbol> symbols, List<Diagnostic> diagnostics) {
|
||||
public Result {
|
||||
symbols = List.copyOf(symbols);
|
||||
diagnostics = List.copyOf(diagnostics);
|
||||
}
|
||||
|
||||
public boolean randomFallback() { return symbols.isEmpty() && diagnostics.isEmpty(); }
|
||||
public boolean canCreate() { return diagnostics.stream().noneMatch(Diagnostic::error); }
|
||||
public Optional<Diagnostic> firstError() { return diagnostics.stream().filter(Diagnostic::error).findFirst(); }
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,16 @@
|
||||
"screen.rivencraft.book_binder.previous_pages": "Scroll pages left (Left Arrow)",
|
||||
"screen.rivencraft.book_binder.next_pages": "Scroll pages right (Right Arrow)",
|
||||
"screen.rivencraft.book_binder.select_hint": "Right-click page to select",
|
||||
"screen.rivencraft.book_binder.grammar.too_many": "Too many pages for a new Age; remove excess pages",
|
||||
"screen.rivencraft.book_binder.grammar.missing_panel": "Add a Link Panel as the first page",
|
||||
"screen.rivencraft.book_binder.grammar.panel_order": "Page %s: move the Link Panel to the first position",
|
||||
"screen.rivencraft.book_binder.grammar.duplicate_panel": "Page %s: remove the extra Link Panel",
|
||||
"screen.rivencraft.book_binder.grammar.unresolved": "Page %s: missing symbol definition; restore its data pack or remove it",
|
||||
"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.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.writing_desk.ink": "Ink: %s/%s",
|
||||
"screen.rivencraft.writing_desk.search": "Search",
|
||||
"screen.rivencraft.writing_desk.sort": "Sort pages by name (A–Z)",
|
||||
@@ -42,6 +52,7 @@
|
||||
"screen.rivencraft.writing_desk.all_categories": "All categories",
|
||||
"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.tab_symbol": "Tab %s",
|
||||
"screen.rivencraft.writing_desk.no_symbols": "No available symbols",
|
||||
"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.",
|
||||
@@ -57,6 +68,7 @@
|
||||
"message.rivencraft.descriptive_book.unwritten": "This Descriptive Book is unwritten",
|
||||
"message.rivencraft.descriptive_book.created": "Created Age: %s",
|
||||
"message.rivencraft.descriptive_book.unavailable": "That Age is unavailable",
|
||||
"message.rivencraft.descriptive_book.invalid_writing": "Writing cannot create an Age: %s",
|
||||
"message.rivencraft.descriptive_book.unsafe": "No safe destination was found in that Age",
|
||||
"message.rivencraft.descriptive_book.linked_first": "Linked to %s — a Return Link was created",
|
||||
"message.rivencraft.descriptive_book.linked": "Linked to %s",
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
{
|
||||
"parent": "minecraft:item/generated",
|
||||
"textures": {
|
||||
"layer0": "rivencraft:item/page"
|
||||
},
|
||||
"overrides": [
|
||||
{ "predicate": { "custom_model_data": 1 }, "model": "rivencraft:item/page_link_panel" },
|
||||
{ "predicate": { "custom_model_data": 2 }, "model": "rivencraft:item/page_unresolved" },
|
||||
{ "predicate": { "custom_model_data": 3 }, "model": "rivencraft:item/page_symbol" },
|
||||
{ "predicate": { "custom_model_data": 100 }, "model": "rivencraft:item/page_symbol_terrain_standard" },
|
||||
{ "predicate": { "custom_model_data": 101 }, "model": "rivencraft:item/page_symbol_biome_native" },
|
||||
{ "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" }
|
||||
]
|
||||
"parent": "builtin/entity",
|
||||
"gui_light": "front",
|
||||
"display": {
|
||||
"ground": { "translation": [0, 2, 0], "scale": [0.5, 0.5, 0.5] },
|
||||
"head": { "rotation": [0, 180, 0], "translation": [0, 13, 7] },
|
||||
"thirdperson_righthand": { "translation": [0, 3, 1], "scale": [0.55, 0.55, 0.55] },
|
||||
"thirdperson_lefthand": { "translation": [0, 3, 1], "scale": [0.55, 0.55, 0.55] },
|
||||
"firstperson_righthand": { "rotation": [0, -90, 25], "translation": [1.13, 3.2, 1.13], "scale": [0.68, 0.68, 0.68] },
|
||||
"firstperson_lefthand": { "rotation": [0, 90, -25], "translation": [1.13, 3.2, 1.13], "scale": [0.68, 0.68, 0.68] },
|
||||
"fixed": { "rotation": [0, 180, 0] }
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.biome.native",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"description": "symbol.rivencraft.weather.normal.description",
|
||||
"category": "rivencraft:weather"
|
||||
"category": "rivencraft:weather",
|
||||
"semantics": { "role": "weather", "value": "normal" },
|
||||
"poem": "Sustain Dynamic Tradition Balance"
|
||||
}
|
||||