Compare commits

..
5 Commits
Author SHA1 Message Date
dnorberg 644d242bba Generate Legacy-style libraries in Ages (#76)
Build / build (push) Canceled after 0s
2026-10-17 11:34:57 -04:00
dnorberg 3423dd25bf Audit Legacy Milestone 7 parity for issue 75
Build / build (push) Canceled after 0s
2026-10-17 02:30:35 -04:00
dnorberg c560e328de Update Gitea URLs after server move
Build / build (push) Canceled after 0s
2026-10-16 16:21:32 -04:00
dnorberg 34ffd317aa Record verified Milestone 6 closure (#74)
Build / build (push) Canceled after 0s
2026-10-12 15:10:17 -04:00
dnorberg c2c2d7a6f8 Complete Milestone 6 acceptance and runtime cleanup (#74)
Build / build (push) Canceled after 0s
2026-10-12 15:07:58 -04:00
31 changed files with 1342 additions and 38 deletions

No files matched your search

+4
View File
@@ -19,6 +19,10 @@ under the same license; desk wood and inlay textures are cropped from desk.png.
The Ink Mixer GUI atlas and block textures are copied from Legacy's
assets/mystcraft/gui/inkmixer.png and textures/blocks/inkmixer_*.png under
the same license. Rivencraft's block model is original and uses those textures.
The bundled mystcraft_library.nbt structure template is a block-layout adaptation
of Mystcraft Legacy's ComponentScatteredFeatureSmallLibrary.java at commit
9bc8ddc061845df0cd5ea47f4fdef773cff786ae, under LGPL-3.0. The conversion
script at scripts/generate_legacy_library_template.py is included to rebuild it.
The initial build layout is derived from the official NeoForge 1.21.1
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB

+7 -1
View File
@@ -1,5 +1,11 @@
# Frozen Age instability score (Milestone 6, issue #64)
The original issue #64 notes below describe the frozen authored score. Later
Milestone 6 slices added a separate live block-profile score. For new
version-4 plans, ordered period/phase modifiers combine until consumed by a
celestial page; each modifier kind left dangling at the end adds +100. Saved
version-3 plans keep their earlier stage-sorted meaning and writing cost.
Every newly created Age now gets a version-1 `Instability` compound inside its server-owned `rivencraft_ages` saved definition. It contains the total `Score` and an ordered `Breakdown` of reason, original page index (zero-based, or -1 for an Age-wide cause), points, and explanation. `AgeRegistrySavedData.instability(UUID)` is the read-only server query. No client tooltip or live data-pack lookup is needed to read a saved score.
The baseline is explicitly zero for page-free and Link-Panel-only random Ages, and for authored Ages. Incomplete writing receives seed-based completion and a zero-point explanatory entry. Supported writing defects use the provisional Milestone 6 grammar costs: duplicate/conflicting single-choice pages +500, out-of-order or misplaced pages +100. Legacy's audited Bright Lighting symbol adds +500 for every occurrence, including a seed-generated controller. Mineshafts, Nether Fortresses, Strongholds, and Villages add +100 for the fourth and every later occurrence. By project balance decision, Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins follow that same rule. The first three occurrences and all other audited Legacy feature symbols have no direct page cost. An authored structure may override its vanilla biome eligibility; every structure-page occurrence adds +100 if **any** selected biome is unsupported, even when another selected biome is compatible. Seed completion still prefers compatible biomes and therefore avoids that cost when it can. Other modern-only features remain neutral. The frozen score selects a deterministic, persisted provider plan through the Milestone 6 runtime. Registered handlers apply that plan only inside loaded Rivencraft Ages and remain subject to the documented scheduler and effect-specific safety bounds.
@@ -12,4 +18,4 @@ without rewriting the authored breakdown or its saved base provider plan.
Unknown or unsupported pages remain invalid for *new* Ages. Their absence from a later registry does not change an already saved score. Focused GameTests cover stable/defective writing, random baseline and bright lighting, repeated identity, save round-trip, and missing-symbol/old-save behavior.
`/rivencraft age instability` inspects the Age containing the command source; an optional dimension ID inspects an unloaded or different registered Age. It reports immutable base score, stability bonus, runtime penalty, and effective score. The pinned Legacy tree defines player-death and player-presence bonus classes but does not register them as default providers, so Rivencraft leaves player-driven modifiers disabled: bonus and penalty are zero and effective equals base. There is therefore no mutable bonus save to migrate in this release. No death, PvP event, player identity, or dimension transition can transfer stability between Ages. Any future opt-in mechanic must persist separately from `Instability`, define caps and reset behavior, and must not rewrite the generation plan or frozen provider plan.
`/rivencraft age instability` inspects the Age containing the command source; an optional dimension ID inspects an unloaded or different registered Age. It reports immutable authored base score, stability bonus, runtime penalty, profiled-block contribution, and difficulty-scaled effective score. The pinned Legacy tree defines player-death and player-presence bonus classes but does not register them as default providers, so Rivencraft leaves player-driven modifiers disabled: bonus and penalty are zero. The live block contribution may still change as chunks are profiled. There is no mutable player-bonus save to migrate in this release. No death, PvP event, player identity, or dimension transition can transfer stability between Ages. Any future opt-in player mechanic must persist separately from `Instability`, define caps and reset behavior, and must not rewrite the generation plan or frozen provider plan.
+7
View File
@@ -2,6 +2,13 @@
This is the behavior contract used to implement Milestone 6. It is pinned to [Mystcraft Legacy source at commit `9bc8ddc`](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae). Values below are source defaults, not a claim that every historical config or server used them. Rivencraft now persists frozen writing-instability scores and provider plans and runs the implemented providers through a bounded, Age-scoped scheduler. The audit remains authoritative for Legacy behavior; the linked runtime documents identify modern adaptations and remaining gaps.
Current new Age plans (schema 4) also implement Legacy's ordered period/phase
consumption and +100 for each modifier kind still pending after the last
celestial page. Repeated same-kind pages combine before consumption rather
than charging a replacement penalty. Older plans keep their saved score and
earlier stage-sorted time interpretation. The older #65 implementation notes
below are historical where they mention a generic misplaced-modifier cost.
## Score, grammar, and selection in Legacy
Legacy's [`AgeController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java) calculates `debug + symbol + block + book/base + bonus`, then applies difficulty 0/1/2/3 multipliers of 0.25/0.5/1/1.75. Global instability is enabled by default; an Age also has its own enable flag. The unmodified score is not simply “number of bad pages.” Block profiling may delay a usable score: `blockinstability` is null until **more than** 400 Age chunks are profiled, and `getInstabilityScore()` returns zero until then. It is recalculated after additional profiling. [`ChunkProfiler`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/profiling/ChunkProfiler.java) compares watched block abundance with a baseline, subtracting only positive excess above each baseline. The baseline is profiled or read from balance config; [`InstabilityDataCalculator`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/profiling/InstabilityDataCalculator.java) sets its default tolerance to 1.05, and default `useconfigs=false`. Rivencraft's later [bounded implementation](LIVE_BLOCK_INSTABILITY.md) retains the threshold and tolerance, but not Legacy's spatial accessibility formula or unbounded profiling.
+57
View File
@@ -0,0 +1,57 @@
# Milestone 7 Legacy audit
Baseline: [Mystcraft Legacy source at `9bc8ddc` (0.13.7.06)](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae), checked against the local 0.13.7.06 asset/class extraction. Source and reused assets are LGPL-3.0; see [asset provenance](ASSET_PROVENANCE.md). This records behavior targets, not byte-for-byte 1.12.2 world generation.
## Already implemented
Rivencraft has books, Ages, symbol writing, book stands, a desk, binder, Ink Mixer, link modifiers, safe travel, and optional previews. Crystal Formations, the blue Crystal Block, budding crystals, shards, and Star Fissures were completed in Milestone 5; #76 and #77 must not reimplement them. Vanilla and modern structures are separately page-gated. The Mystcraft library below is neither one of those structures nor a player-authorable page.
## Libraries: target for #76
- [`ChunkProviderMyst`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/ChunkProviderMyst.java#L46-L47) installs a scattered-library generator in every Age. [`MapGenScatteredFeatureMyst`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/gen/structure/MapGenScatteredFeatureMyst.java#L34-L103) picks one candidate per 32×32-chunk region with 0–23 chunk offsets and salt `14357617`. Its biome restriction is commented out. There is no Library page and no nearest-structure implementation. Keep automatic sparse placement, modernized for current structure spacing and height APIs, without forcing distant chunks.
- [`ComponentScatteredFeatureSmallLibrary`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/gen/structure/ComponentScatteredFeatureSmallLibrary.java#L19-L70) makes one randomly rotated 11×10×11 building at average ground height, with downward cobblestone supports. Its palette is cobblestone, stone stairs/slabs, cobble walls, planks, bookshelves, webs, and air. Preserve its recognizable shape and interior; define safe placement for Cave/Void/Sky Island terrain rather than fabricating ground.
- [Loot placement](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/gen/structure/ComponentScatteredFeatureSmallLibrary.java#L762-L791) puts one chest at local `(4,1,2)` and up to five book-bearing lecterns at `(6,2,2)`, `(8,2,4..6)`, `(6,2,8)`. The [treasure table](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/loot_tables/mystcraft_treasure.json) rolls 4–8 plus 1–2 bonus rolls: dynamically registered symbol pages and static vial/booster/leather/paper. [`LootTableHandler`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/treasure/LootTableHandler.java#L69-L94) rerolls lectern contents up to 100 times and requires symbol rank ≥3. Use data-driven server loot, document substitutes for unavailable items, and prevent loot duplication after restart.
- The attempted hook into desert pyramid, jungle temple, stronghold library, and dungeon loot uses [`TreasureGenWrapper`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/treasure/TreasureGenWrapper.java), whose `addLoot` is empty in this revision. It is not evidence that those vanilla chests actually yield pages.
## Crystal portals: targets for #77–#78
## Library implementation (#76)
The operator-only test controls are `/rivencraft library test-spacing 8` (roughly 16× normal candidate density in newly generated chunks), `/rivencraft library test-spacing` (show the current setting), and `/rivencraft library test-spacing reset` (return to Legacy spacing). The override affects only the current Age, lasts only until server restart, and never retroactively places libraries in existing chunks. Normal sites remain eligible. A spacing of `1` attempts a candidate in every new chunk and may be expensive. `/rivencraft library nearest` searches nearby test sites while the override is active. `/rivencraft library test-place` is a separate OP-only manual test: it places a library about 13 blocks ahead of the player in the current Age if an 11×11×11 volume is clear, building a one-block cobblestone platform below it. The command reports the chest coordinates; it does not alter natural spawning or make the manual library a locate candidate. Move to open ground if the command rejects the site.
Libraries now use Legacy's 32×32-chunk region and 0–23 offset with salt `14357617`, one candidate per region. A candidate builds only on a dry, sturdy, nearly level 11×11 surface with clear headroom; its entire rotated footprint stays in that one chunk. Cave terrain is skipped, Void naturally fails the ground check, and Sky Islands can host a library only on a suitable island top. Rejected candidate sites are not retried elsewhere in the region. This is intentionally safer and sparser than cutting through a modern village, tree, ocean, or cliff. The building now places `data/rivencraft/structure/mystcraft_library.nbt`, generated from all 694 ordered Legacy block placements. The old two-block lectern locations are emptied in the template and replaced at runtime by one-block Rivencraft Book Stands with page loot. The chest is also populated at runtime. Regenerate the template with `python scripts/generate_legacy_library_template.py`; the script pins and checksums the Legacy source.
The cobblestone supports, stone stairs/slabs, planks, shelves, webs, one chest, and five book stands now follow Legacy's block layout via the packaged template. Players can also place a page on an empty vanilla Lectern, converting it to a Rivencraft Book Stand; sneak-use retrieves the page. The stand renders the page item and its symbol name rather than a book model. `libraries.loot_mode` is a server config with `LEGACY` (default) and `AGE_ONLY` choices. In `LEGACY`, the chest uses `data/rivencraft/loot_table/chests/mystcraft_library.json`, with 4–8 main and 1–2 bonus rolls; Rivencraft Ink Vials replace Legacy vials, and ordinary books/experience bottles replace its unavailable booster item. Stands use random catalog symbol pages, excluding generated block/biome entries. In `AGE_ONLY`, the chest and stands contain only pages from the Age's frozen authored active symbols, or its selected random terrain/features/biome pool; there are no supplies. If an Age has no eligible symbols, the stand falls back to an ordinary book. The Legacy rank ≥3 gate cannot be carried over because Rivencraft symbols have no rank field. A stand's page and Age-only chest pages are saved in block entities; Legacy chests keep their assigned loot table/seed. Neither rerolls on player interaction or restart. Config changes affect only future libraries; existing generated chunks are never retroactively populated.
For a manual check, create a fresh surface Age and run `/rivencraft library nearest` as an operator. It examines up to 128 deterministic candidates in a six-region radius, one candidate chunk per server tick; this can generate those chunks. It teleports only to a marked intact library with clear floor/headroom, or reports that none was found. Confirm one chest and up to five page-bearing stands, take a page and chest contents, leave/restart, and confirm they do not reappear. Right-click an empty vanilla Lectern with a Symbol Page; it should become a stand displaying that page and symbol name. Sneak-right-click with an empty hand to retrieve it and restore the vanilla Lectern. Switch `libraries.loot_mode` to `AGE_ONLY`, create another fresh Age, and confirm its new library contains only pages that describe that Age (no paper, books, ink or bottles); switching modes must not refill an existing library. Repeat with a second player and with Flat, Cave, Void, and Sky Island Ages: only suitable exposed surfaces should host a library. The archived generation GameTest checks deterministic spacing, negative-coordinate regions, the single-chunk footprint, Age-only page filtering, stand storage, and locator marker detection.
- A [Book Receptacle](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/block/BlockBookReceptacle.java#L58-L173) attaches to a Crystal Block on any face except downward and holds a Linking or Descriptive Book. The Legacy recipe is eight Crystal Blocks around an empty center. Removing the book or its crystal support deactivates the portal; breaking the receptacle drops the book. [`TileEntityBookReceptacle`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/tileentity/TileEntityBookReceptacle.java#L23-L40) activates/shuts down on server-side book changes.
- [`PortalUtils`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/portal/PortalUtils.java#L23-L104) grows portal cells through a connected crystal network. Its tension test requires opposing crystal/portal neighbors on at least two axes; this is more flexible than a single fixed rectangular frame. Active cells trace a path to one receptacle. Modernize the unbounded flood/path walks with loaded-chunk and volume bounds, server validation, and persistent block-entity state.
- [`BlockLinkPortal`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/block/BlockLinkPortal.java#L208-L256) is translucent, non-solid, lit, validated on neighbor changes/random ticks, tinted by the book destination, and routes collisions to the receptacle's book. [`ItemLinking.onPortalCollision`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/item/ItemLinking.java#L106-L119) marks travel external, maintains momentum, disables platform generation, and uses the portal sound. The book remains in the receptacle. #77 owns core behavior; #78 owns restart recovery, protection, multiplayer, cooldowns, chunk tickets, and deleted destinations. A buildable portal is distinct from the existing Star Fissure.
- [Legacy portal assets](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft) include `textures/blocks/book_receptacle.png`, `crystal.png`, animated `portal.png` with its `.mcmeta`, receptacle/crystal/portal block models and blockstates, `textures/entity/linkbook.png`, and `sounds/linking/link-portal.ogg`. Check these before creating art for #80, and add each reused file to [provenance](ASSET_PROVENANCE.md). Legacy's rendering pipeline itself cannot be copied unchanged to 1.21.1.
## Handheld books and Return Links
[`ItemLinking.activate`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/item/ItemLinking.java#L79-L103) calls `onLink` before travel. If the player uses the exact book in the selected main-hand slot and the Following flag is absent, it removes the book and spawns an [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L48-L65) in the **origin** world. Following books stay in hand. The entity can be opened, picked up by sneaking with an empty hand, damaged, and collected by a hopper minecart.
Issue #75's comment calls this a despawning drop. The code differs subtly: [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L190-L224) explicitly cannot despawn, but takes one starvation damage every 10,000 ticks and damage while wet. It can eventually be destroyed if left unattended; a vanilla item entity's fixed five-minute timeout is not parity. A modern equivalent should retain the visible book and avoid silently deleting its Age identity at that timeout.
Legacy's travel path creates no automatic Return Link. Rivencraft currently defaults to `ON_CREATION` via `DescriptiveBookItem` and `descriptive_books.return_link_mode`. Per #75's comment, change the **new-config default** to `DISABLED` while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs must keep their explicit value. Handheld-book drop and Return Link default are tracked in [#85](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/85), not portal-frame logic.
## Crafting, sounds, and progression
| Area | Legacy evidence and modern boundary |
| --- | --- |
| Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft already has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, and Linking Book recipes. #79 adds only missing recipes/processes; preserve vanilla-Lectern conversion, noncraftable Blank Pages, and optional JEI. |
| Sounds | [`Sounds`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/Sounds.java#L8-L26) registers pop, link, disarm, following, intra-link, fissure-link, portal-link, and meteor roar. The matching OGGs are in the local extraction. Rivencraft already carries link, fissure-link, and meteor roar. #80 should reuse and credit available Legacy files before creating alternatives. |
| Advancements | [Legacy advancement JSONs](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/advancements) have inventory milestones for a symbol page, unlinked Linking Book, and Age Book; a Writing Desk trigger; a safe Age-travel trigger; and a hidden Quinn trigger. #81 should use modern server-owned criteria. Quinn has no established Rivencraft equivalent and should be documented or deliberately omitted, not awarded on preview. |
## Test boundaries and modern adaptations
- #76: deterministic sparse, automatic libraries with the 11×10×11 shape, chest and up to five book stands; test restart and nonstandard terrain without forcing distant chunks.
- #77: receptacle/crystal activation, connected-frame validation, sound/color, travel through the existing book/Age service, and rejection of invalid books or frames.
- #78: full-restart recovery, multiplayer, protection events, loop prevention, destination deletion, and bounded chunk activity.
- #79–#81: data-driven recipes/optional JEI, then assets/audio, then advancements. #82–#83 verify NeoForge 1.21.1 compatibility and a focused manual/automated matrix.
- #85: drop active non-Following books at origin, disable Return Links for **new** configs, preserve existing config choices, and separately test held, stand, and portal travel.
The 1.21.1 world height, biome/structure APIs, dynamic Age lifecycle, NeoForge protection events, and dedicated-server rendering boundaries preclude direct transplantation of Legacy's 1.12 generator, metadata, and fixed-function portal renderer. Keep those adaptations explicit in follow-up tests and documentation.
+1 -1
View File
@@ -1,6 +1,6 @@
# Milestone 5 acceptance: Age grammar
This is the completion record for [Gitea issue #42](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/issues/42) and release 0.5.0.0. Issues #28–#41, #45–#59, #61, and #62 are closed. #39 was closed with an explicit Ecliptic Seasons limitation, not with a working Ecliptic adapter. #42 is the final gate. Issues #43, #44, and #60 are separate open follow-ups, not hidden Milestone 5 completion claims.
This is the completion record for [Gitea issue #42](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/42) and release 0.5.0.0. Issues #28–#41, #45–#59, #61, and #62 are closed. #39 was closed with an explicit Ecliptic Seasons limitation, not with a working Ecliptic adapter. #42 is the final gate. Issues #43, #44, and #60 are separate open follow-ups, not hidden Milestone 5 completion claims.
## Authoring and generation contract
+110
View File
@@ -0,0 +1,110 @@
# Milestone 6 acceptance review (issue #74)
Milestone 6 covers authored and live block instability, deterministic provider
selection, bounded Age effects, diagnostics, and migration. Its required issues
#63–#73 are closed. This document records acceptance of **0.6.0.0**, not a
claim of exact Mystcraft 1.12.2 parity or a claim that every optional-mod
combination was tested. The player reported an in-game pass for the 0.6.0.0
candidate on 2026-10-12; the broader manual matrix remains a future checklist.
## Scoring and writing
The [pinned Legacy audit](LEGACY_INSTABILITY_AUDIT.md) records source defaults
and the Rivencraft mapping. Authored score/breakdown and provider plan are
saved per Age; seed-completed missing branches cost zero. Extra single-choice
controllers cost +500, and out-of-order non-modifier pages cost +100.
Version-4 Age plans consume period/phase modifiers in written order; repeated
modifiers combine, while each kind left unconsumed after the last celestial
page costs +100. Bright Lighting costs +500 per occurrence. The fourth and
later occurrences of each of the four Legacy structure pages cost +100; the
same rule applies to named modern structure pages as a project balance
extension. Forcing a structure into an unsupported selected biome costs +100
per occurrence. Accelerated and Corrupted pages retain their audited costs.
Unknown or unrepresentable new writing remains a clear binding error, rather
than a silent no-op. See [score details](INSTABILITY_SCORE.md).
The [live block profile](LIVE_BLOCK_INSTABILITY.md) samples generated chunks
gradually and contributes only after the Age and Overworld-baseline thresholds.
It can change the effective score and active provider selection as new chunks
are profiled, without rewriting authored score or writing. Diamond storage
blocks are an intentional addition to Legacy's watched blocks. Ores, mineral
storage blocks, and source fluids supplied by installed mods are recognized
automatically through common tags and material names; those mods are **not**
Rivencraft dependencies. Exact weight overrides are optional configuration.
## Effects, safety, and diagnostics
The [runtime policy](INSTABILITY_RUNTIME.md) documents saved deck order,
provider costs/levels, per-Age cooldown, rotating Age admission, loaded-chunk
selection, and server-side action budgets. Defaults allow four Ages per tick,
two provider calls and one effect action per Age run, up to 16 loaded chunk
candidates, and a 20-tick per-Age cooldown. These caps and protected
spawn/portal/platform areas are modern safety adaptations, not Legacy's
unrestricted execution. Global, per-Age, provider, and world-mutation controls
suppress execution without rerolling a saved authored plan.
Implemented effect families include bounded status effects, four active
colored decay variants, lightning, explosions, meteors, ordinary burning,
and guarded erosion. Erosion is deliberately enabled by default for testing
although Legacy did not register its provider in the active deck. Dormant
Legacy black/green/yellow decay variants, global burning, and bedrock crumble
are **not** claimed as working. Potion targeting excludes Creative/Spectator
players; hostile buffs target hostile mobs. The [effect audit](LEGACY_INSTABILITY_AUDIT.md)
records those differences. [Operator diagnostics](INSTABILITY_DIAGNOSTICS.md)
report authored and live block contributions, selected providers, enabled
state, and budget suppression; raw score is not shown in book UI.
## Migration and validation
Pre-scoring Ages migrate to neutral instability. Score-only Ages receive a
deterministic saved provider plan. Existing plan-v3 Ages keep their old
stage-sorted celestial behavior and writing cost; new plans use schema 4.
Block profile schema 2 resets old samples once to capture modded materials,
and preserves unknown material IDs if an optional mod is later removed.
Missing symbol definitions do not reinterpret a saved authored score, but
removing required biome/worldgen registry entries may still prevent world
loading. General old-save compatibility is tracked separately in issue #60.
The [Milestone 6 regression record](MILESTONE_6_REGRESSION.md) distinguishes
the passing 0.5.8.10 focused archive, the later storage-stalled archive, the
0.5.8.18 build, and the player's reported in-game pass. Its expanded manual
matrix is a repeatable future checklist, **not** an individually signed-off
pass for every optional mod, multiplayer, restart, and hazard combination.
For this completion candidate, verify build, diff check, jar checksum, and a
fresh in-game pass before publication. Do not rerun the known stalling archive
as a release gate; hand any newly stalled GameTest scenario to the player.
The completion review also replaced repeated linear scans of saved active
symbol indices with one lookup map per call, and shares one effective provider
plan across the scheduler, hazard, and environmental lanes of an admitted Age
tick. Live-profile scoring within that plan also uses one sample evaluation
instead of two. These are
behavior-preserving cleanups; no provider rates or budgets were raised.
## NeoForge decision
The project currently develops against NeoForge **21.1.251** for Minecraft
1.21.1. The [official 21.1.259 changelog](https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.259/neoforge-21.1.259-changelog.txt)
lists mostly loader/Mixin dependency updates, a GUI slot-index API change,
and targeted fixes since 21.1.251. None addresses a known Rivencraft defect.
Upgrading the development and minimum runtime version during this acceptance
gate would force an additional optional-integration test pass for little clear
benefit. Keep 21.1.251 for 0.6.0.0; evaluate 21.1.259 in a separate later
slice with Create/Ponder, Immersive Portals, Dynamic Dimensions, JEI, and the
season/biome integrations. This is a release-risk judgment, not a claim that
21.1.259 is incompatible.
## Gate status
- Required implementation issues #63–#73: closed.
- Known unsupported effects and broader save compatibility: explicitly scoped
above, not counted as working Milestone 6 behavior.
- Candidate `./gradlew build --offline --no-daemon --quiet`: passed; the
compiler reported only existing GameTest helper deprecation warnings.
Local JAR SHA-256: `6eed828156dd1e2679b013c3c5bf8e692a20d9ab80ae27417b3cafe1c26422c7`.
- Player in-game acceptance of completion candidate: reported pass on
2026-10-12; individual optional-mod and multiplayer matrix rows were not
separately signed off.
- [Gitea V0.6.0.0 release](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/releases/tag/V0.6.0.0):
published with changelog and jar; downloaded asset SHA-256 matched the local
build. Issue #74 and Milestone 6 were closed with all 12 milestone issues
closed and zero remaining open.
+10 -10
View File
@@ -25,13 +25,13 @@ must not silently change existing saved Ages.
Gitea is the source of truth for project status:
- [Issues](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/issues) contain
- [Issues](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues) contain
scoped implementation tasks, bugs, acceptance criteria, and technical
discussion.
- [Milestones](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestones)
- [Milestones](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestones)
group issues into the porting phases below and show their live completion
state.
- [Releases](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/releases)
- [Releases](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/releases)
contain user-facing changelogs for shipped versions.
New work should have an issue before implementation begins. Commits should
@@ -48,22 +48,22 @@ acceptance issue and all required implementation issues are complete.
3. **Ages** — persistent generated dimensions, deterministic traits,
lifecycle management, data-pack integration, and optional destination
renderer compatibility. **Complete.** See
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
[Milestone 3](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestone/8).
4. **Writing system** — pages, notebooks, desks, book assembly, and the baseline
Ink Mixer for Link Panels. Symbol effects were deferred to Milestone 5.
**Complete.** See [Milestone 4](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/9)
**Complete.** See [Milestone 4](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestone/9)
and [writing-system data and migration](WRITING_DATA.md).
5. **Age grammar** — translate supported symbol combinations into modern biome,
noise, dimension-type, and world-generation configuration, with optional
TerraBlender and Serene Seasons integration. **Complete for the supported
subset.** See the [acceptance record](MILESTONE_5_ACCEPTANCE.md) and
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md).
6. **Instability** — deterministic validation and server-safe effects. **In
progress.** Start with the
[Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md), which separates
source behavior from proposed 1.21.1 safety limits.
6. **Instability** — deterministic validation and server-safe effects.
**Complete.** See the [Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md)
and [Milestone 6 acceptance](MILESTONE_6_ACCEPTANCE.md).
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
sounds, advancements, compatibility, and migration tests.
sounds, advancements, compatibility, and migration tests. Start with the
[Milestone 7 Legacy audit](LEGACY_M7_AUDIT.md).
## Architectural direction
+1 -1
View File
@@ -36,7 +36,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.5.8.18
mod_version=0.6.1.11
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=B6nTjh4b
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Rebuild the bundled 1.21.1 structure template from Mystcraft Legacy's layout.
Run manually; world generation never downloads anything. The pinned Legacy source
is LGPL-3.0 and its original block placement order is preserved here.
"""
import gzip
import hashlib
import re
import struct
import urllib.request
from pathlib import Path
SOURCE = ("https://raw.githubusercontent.com/Mystcraft/Mystcraft-Legacy/"
"9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/"
"com/xcompwiz/mystcraft/world/gen/structure/ComponentScatteredFeatureSmallLibrary.java")
SHA256 = "487b3bef48dacfbea35ac2020dbb5543dab0c2a9d99bae4a11acb5cc6fc8bfac"
OUTPUT = Path(__file__).resolve().parents[1] / "src/main/resources/data/rivencraft/structure/mystcraft_library.nbt"
LECTERNS = {(6, 2), (8, 4), (8, 5), (8, 6), (6, 8)}
BLOCKS = {
"AIR": "air", "BOOKSHELF": "bookshelf", "COBBLESTONE": "cobblestone",
"COBBLESTONE_WALL": "cobblestone_wall", "PLANKS": "oak_planks",
"STONE_STAIRS": "stone_stairs", "STONE_SLAB": "cobblestone_slab", "WEB": "cobweb",
}
PATTERN = re.compile(r"setBlockState\(worldObj, Blocks\.(\w+)\.(.+?), "
r"(\d+), (\d+), (\d+), boundingbox\);")
def nbt_string(value):
encoded = value.encode("utf-8")
return struct.pack(">H", len(encoded)) + encoded
def tag(kind, name, payload):
return bytes([kind]) + nbt_string(name) + payload
def compound(entries):
return b"".join(entries) + b"\0"
def list_tag(name, element_kind, elements):
return tag(9, name, bytes([element_kind]) + struct.pack(">i", len(elements)) + b"".join(elements))
def int_list(name, values):
return list_tag(name, 3, [struct.pack(">i", value) for value in values])
def main():
source = urllib.request.urlopen(SOURCE, timeout=15).read()
if hashlib.sha256(source).hexdigest() != SHA256:
raise ValueError("Pinned Legacy source changed")
placements = {}
matches = PATTERN.findall(source.decode("utf-8"))
if len(matches) != 694:
raise ValueError(f"Expected 694 Legacy block placements, got {len(matches)}")
for legacy_block, state, x, y, z in matches:
x, y, z = int(x), int(y), int(z)
if legacy_block not in BLOCKS or not (0 <= x <= 10 and 0 <= y <= 10 and 0 <= z <= 10):
raise ValueError(f"Unsupported Legacy block/position: {legacy_block} {(x, y, z)}")
properties = {}
facing = re.search(r"BlockStairs.FACING, EnumFacing.(\w+)", state)
if facing:
properties["facing"] = facing.group(1).lower()
if "BlockStairs.EnumHalf.TOP" in state:
properties["half"] = "top"
if legacy_block == "STONE_SLAB":
meta = re.search(r"getStateFromMeta\((\d+)\)", state)
if not meta or int(meta.group(1)) not in (3, 11):
raise ValueError(f"Unknown Legacy slab metadata: {state}")
properties["type"] = "top" if int(meta.group(1)) == 11 else "bottom"
placements[x, y, z] = ("minecraft:" + BLOCKS[legacy_block], tuple(sorted(properties.items())))
# Legacy's lower lectern is a bookshelf at y=1. Remove that and its
# two-block upper half; runtime places a one-block Rivencraft stand at y=1.
for x, z in LECTERNS:
placements.pop((x, 1, z), None)
placements.pop((x, 2, z), None)
# Chest and its inventory are assigned at runtime, never frozen in NBT.
placements.pop((4, 1, 2), None)
placements = {pos: state for pos, state in placements.items() if state[0] != "minecraft:air"}
palette = sorted(set(placements.values()))
indexes = {state: index for index, state in enumerate(palette)}
palette_tags = []
for name, properties in palette:
entries = [tag(8, "Name", nbt_string(name))]
if properties:
entries.append(tag(10, "Properties", compound(
[tag(8, key, nbt_string(value)) for key, value in properties])))
palette_tags.append(compound(entries))
block_tags = [compound([int_list("pos", (x, y, z)),
tag(3, "state", struct.pack(">i", indexes[state]))])
for (x, y, z), state in sorted(placements.items())]
root = compound([int_list("size", (11, 11, 11)), list_tag("palette", 10, palette_tags),
list_tag("blocks", 10, block_tags), list_tag("entities", 10, []),
tag(3, "DataVersion", struct.pack(">i", 3955))])
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_bytes(gzip.compress(tag(10, "", root), mtime=0))
print(f"Wrote {OUTPUT}: {len(placements)} blocks, {len(palette)} palette states")
if __name__ == "__main__":
main()
@@ -15,6 +15,7 @@ import net.dnorberg.rivencraft.event.BookStandConversionHandler;
import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
import net.dnorberg.rivencraft.event.AgeLifecycleEvents;
import net.dnorberg.rivencraft.command.RivencraftCommands;
import net.dnorberg.rivencraft.command.LibraryLocateService;
import net.dnorberg.rivencraft.network.ModNetworking;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
@@ -80,6 +81,8 @@ public final class Rivencraft {
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerTickPost);
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeRuntimeProfiles::register);
NeoForge.EVENT_BUS.addListener(SymbolCatalog::register);
LOGGER.info("Initializing Rivencraft");
@@ -289,8 +289,9 @@ public final class AgeBlockInstability {
public static AgeInstabilityPlan effectivePlan(ServerLevel level, AgeDefinition age) {
int blockScore = score(level, age);
if (blockScore == 0 && RivencraftConfig.instabilityDifficulty() == 2) return age.instabilityPlan();
int combined = effectiveScore(level, age);
int difficulty = RivencraftConfig.instabilityDifficulty();
if (blockScore == 0 && difficulty == 2) return age.instabilityPlan();
int combined = difficultyScore((long) age.instability().score() + blockScore, difficulty);
AgeInstability effective = new AgeInstability(AgeInstability.CURRENT_VERSION, combined,
java.util.List.of(new AgeInstability.Contribution("effective_block_profile", -1, combined,
"Saved writing score plus sampled excess blocks")));
@@ -17,6 +17,10 @@ import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
@@ -312,6 +316,7 @@ public final class AgeDimensionService {
private static ChunkGenerator createGenerator(MinecraftServer server, AgeDefinition age) {
AgeRuntimeProfile profile = runtimeProfile(age);
BiomeSource biomeSource = createBiomeSource(server, age, profile);
List<ResourceLocation> libraryAgeSymbols = libraryAgeSymbols(age, profile, biomeSource);
List<AgeFeatureSelection.Choice> features = age.plan() == null
? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan());
if (profile.terrain() == AgeRuntimeProfile.Terrain.FLAT) {
@@ -321,7 +326,7 @@ public final class AgeDimensionService {
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
);
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed());
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
}
if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
FlatLevelGeneratorSettings settings = new FlatLevelGeneratorSettings(
@@ -331,7 +336,7 @@ public final class AgeDimensionService {
);
settings.updateLayers();
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed());
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
}
var noiseSettings = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS);
@@ -343,7 +348,40 @@ public final class AgeDimensionService {
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
? new NoiseBasedChunkGenerator(biomeSource, settings)
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed(),
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of());
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of(),
libraryAgeSymbols);
}
/** Freeze the Age's authoring/result pages into the generator; worldgen never reads SavedData. */
public static List<ResourceLocation> libraryAgeSymbols(AgeDefinition age, AgeRuntimeProfile profile,
BiomeSource biomes) {
LinkedHashSet<ResourceLocation> ids = new LinkedHashSet<>();
ResourceLocation effectiveTerrain = AgeRuntimeProfiles.profiles().entrySet().stream()
.filter(entry -> entry.getValue().equals(profile))
.map(java.util.Map.Entry::getKey).sorted().findFirst()
.orElse(AgeGenerationProfiles.migrationFallback(age.generationProfile()));
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
"terrain/" + effectiveTerrain.getPath()));
if (age.plan() != null) {
age.plan().activeSymbols().forEach(symbol -> ids.add(symbol.reference().id()));
} else {
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
"biome/" + age.traits().biomeDistribution().name().toLowerCase(java.util.Locale.ROOT)));
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
"lighting/" + age.traits().lighting().name().toLowerCase(java.util.Locale.ROOT)));
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
"weather/" + age.traits().weather().name().toLowerCase(java.util.Locale.ROOT)));
age.randomFeatures().forEach(choice -> {
ids.add(ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/" + choice.id().getPath()));
choice.material().ifPresent(material -> ids.add(BlockSymbolPages.symbolId(material)));
});
biomes.possibleBiomes().forEach(holder -> holder.unwrapKey().ifPresent(key -> {
if (BiomeSymbolPages.isPublicPageBiome(key.location()))
ids.add(BiomeSymbolPages.symbolId(key.location()));
}));
}
return ids.stream().filter(id -> SymbolCatalog.isKnown(new SymbolReference(id))).toList();
}
private static NoiseGeneratorSettings withBiomeMaterialRules(NoiseGeneratorSettings base) {
@@ -93,8 +93,9 @@ public final class AgeEnvironmentalProviders {
return METEORS.equals(id) || BURNING.equals(id) || EROSION.equals(id);
}
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick) {
List<AgeInstabilityPlan.ProviderLevel> cards = AgeBlockInstability.effectivePlan(level, age).providers().stream()
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick,
List<AgeInstabilityPlan.ProviderLevel> providers) {
List<AgeInstabilityPlan.ProviderLevel> cards = providers.stream()
.filter(card -> isEnvironmental(card.id())).toList();
boolean optInErosion = RivencraftConfig.erosionEnabled()
&& AgeBlockInstability.effectiveScore(level, age) >= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost();
@@ -1,8 +1,12 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
@@ -44,11 +48,14 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
if (version >= 3 && !symbols.isEmpty() && activeSymbolIndices.isEmpty()) {
throw new IllegalArgumentException("Version 3 Age plan has no active symbols");
}
if (activeSymbolIndices.stream().distinct().count() != activeSymbolIndices.size()) {
throw new IllegalArgumentException("Age plan repeats an active symbol index");
}
Set<Integer> resolvedIndices = new HashSet<>();
for (ResolvedSymbol symbol : symbols) resolvedIndices.add(symbol.index());
Set<Integer> seenActive = new HashSet<>();
for (int index : activeSymbolIndices) {
if (symbols.stream().noneMatch(symbol -> symbol.index() == index)) {
if (!seenActive.add(index)) {
throw new IllegalArgumentException("Age plan repeats an active symbol index");
}
if (!resolvedIndices.contains(index)) {
throw new IllegalArgumentException("Active symbol is not in the saved writing: " + index);
}
}
@@ -107,8 +114,13 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
}
public List<ResolvedSymbol> activeSymbols() {
return activeSymbolIndices.stream().map(index -> symbols.stream()
.filter(symbol -> symbol.index() == index).findFirst().orElseThrow()).toList();
Map<Integer, ResolvedSymbol> byIndex = new HashMap<>(symbols.size());
for (ResolvedSymbol symbol : symbols) byIndex.putIfAbsent(symbol.index(), symbol);
return activeSymbolIndices.stream().map(index -> {
ResolvedSymbol symbol = byIndex.get(index);
if (symbol == null) throw new IllegalStateException("Missing active symbol " + index);
return symbol;
}).toList();
}
public CompoundTag save() {
@@ -55,9 +55,10 @@ public final class AgeHazardProviders {
}
/** Roll every selected hazard card each scheduler pass, independently of other providers. */
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick) {
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick,
List<AgeInstabilityPlan.ProviderLevel> providers) {
if (!RivencraftConfig.hazardsEnabled()) return;
List<AgeInstabilityPlan.ProviderLevel> hazards = AgeBlockInstability.effectivePlan(level, age).providers().stream()
List<AgeInstabilityPlan.ProviderLevel> hazards = providers.stream()
.filter(provider -> isHazard(provider.id())).toList();
if (hazards.isEmpty()) return;
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.hazardsMaxChunks());
@@ -55,16 +55,16 @@ public final class AgeInstabilityRuntime {
if (!admitted(state.eligible, level.dimension().location(), tick / cooldown,
RivencraftConfig.instabilityMaxAges())) return;
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null || AgeBlockInstability.effectivePlan(level, age).providers().isEmpty()
|| !AgeInstabilityControls.get(level).enabled(age.id())) return;
if (age == null || !AgeInstabilityControls.get(level).enabled(age.id())) return;
List<AgeInstabilityPlan.ProviderLevel> providers = AgeBlockInstability.effectivePlan(level, age).providers();
if (providers.isEmpty()) return;
// Legacy's per-chunk hazards must not wait for the rotating two-provider sample.
AgeHazardProviders.tickPlanned(level, age, tick);
AgeEnvironmentalProviders.tickPlanned(level, age, tick);
AgeHazardProviders.tickPlanned(level, age, tick, providers);
AgeEnvironmentalProviders.tickPlanned(level, age, tick, providers);
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.instabilityMaxChunks());
if (chunks.isEmpty()) return;
Budget budget = new Budget(RivencraftConfig.instabilityMaxActions());
Context context = new Context(level, age, chunks, tick, budget);
List<AgeInstabilityPlan.ProviderLevel> providers = AgeBlockInstability.effectivePlan(level, age).providers();
int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size());
int ran = 0;
for (int offset = 0; offset < providers.size() && ran < RivencraftConfig.instabilityMaxProviders()
@@ -15,8 +15,10 @@ import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.level.block.state.BlockState;
import org.joml.Matrix4f;
@@ -49,6 +51,26 @@ public final class BookStandRenderer implements BlockEntityRenderer<BookStandBlo
return;
}
if (blockEntity.getBook().is(ModItems.PAGE.get())) {
poseStack.pushPose();
poseStack.translate(0.5F, 1.0625F, 0.5F);
// The page's long axis follows the lectern top. The book model below uses
// its own axis convention, but applying that yaw here turns a flat page 90°.
float rotation = state.getValue(BookStandBlock.FACING).toYRot() - 180.0F;
// The east/west page needs a horizontal half-turn, before the
// lectern's incline is applied. A local Z turn spins the art only.
if (state.getValue(BookStandBlock.FACING).getAxis() == Direction.Axis.X)
rotation += 180.0F;
poseStack.mulPose(Axis.YP.rotationDegrees(rotation));
poseStack.mulPose(Axis.XP.rotationDegrees(67.5F));
poseStack.scale(0.7F, 0.7F, 0.7F);
Minecraft.getInstance().getItemRenderer().renderStatic(blockEntity.getBook(),
ItemDisplayContext.FIXED, packedLight, packedOverlay, poseStack, bufferSource,
blockEntity.getLevel(), 0);
poseStack.popPose();
return;
}
poseStack.pushPose();
poseStack.translate(0.5F, 1.0625F, 0.5F);
float rotation = state.getValue(BookStandBlock.FACING).getClockWise().toYRot();
@@ -0,0 +1,293 @@
package net.dnorberg.rivencraft.command;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashSet;
import java.util.UUID;
import net.dnorberg.rivencraft.book.AgeBiomeSelection;
import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.AgeRuntimeProfile;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.dnorberg.rivencraft.world.MystcraftLibraryFeature;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
/** Bounded one-candidate-per-tick search; only an operator can start it. */
public final class LibraryLocateService {
private static final int REGION_RADIUS = 6;
private static final int MAX_CANDIDATES = 128;
private static final ArrayDeque<Search> QUEUE = new ArrayDeque<>();
private static final Map<UUID, Search> ACTIVE = new HashMap<>();
private LibraryLocateService() {}
private record Search(MinecraftServer server, UUID playerId, ServerLevel level, long seed,
int spacing, List<ChunkPos> candidates, int next) {
Search advance() {
return new Search(server, playerId, level, seed, spacing, candidates, next + 1);
}
}
public static int testSpacing(CommandSourceStack source, Integer chunks) {
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("An operator player must run this command."));
return 0;
}
AgeDefinition age = AgeRegistrySavedData.get(player.serverLevel())
.findByDimension(player.serverLevel().dimension().location()).orElse(null);
if (age == null) {
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
return 0;
}
if (chunks != null) MystcraftLibraryFeature.setTestSpacing(age.seed(), chunks);
int spacing = MystcraftLibraryFeature.spacing(age.seed());
source.sendSuccess(() -> Component.literal(spacing == MystcraftLibraryFeature.REGION
? "Library test spacing is off; Legacy 32-chunk spacing is active."
: "Library test spacing: " + spacing + " chunks (about "
+ (MystcraftLibraryFeature.REGION * MystcraftLibraryFeature.REGION / (spacing * spacing))
+ "x normal candidate density). Only new chunks are affected; this resets on server restart."), false);
return spacing;
}
public static int testPlace(CommandSourceStack source) {
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("An operator player must run this command."));
return 0;
}
ServerLevel level = player.serverLevel();
AgeDefinition age = AgeRegistrySavedData.get(level)
.findByDimension(level.dimension().location()).orElse(null);
if (age == null) {
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
return 0;
}
AgeRuntimeProfile profile = age.plan() == null
? AgeRuntimeProfiles.resolve(age.generationProfile()) : age.plan().runtimeProfile();
var symbols = AgeDimensionService.libraryAgeSymbols(age, profile,
level.getChunkSource().getGenerator().getBiomeSource());
var placed = MystcraftLibraryFeature.testPlace(level, player.blockPosition(),
player.getDirection(), age.seed(), symbols);
if (placed.isEmpty()) {
source.sendFailure(Component.literal("Could not place a test library here. Move to an open area "
+ "with clear sky, then try again."));
return 0;
}
BlockPos chest = placed.orElseThrow();
source.sendSuccess(() -> Component.literal("Placed a test library ahead of you. Chest: "
+ chest.getX() + ", " + chest.getY() + ", " + chest.getZ()
+ ". This placement does not change natural library spawning."), false);
return 1;
}
/** Generate and inspect just the nearest deterministic candidate, without changing it. */
public static int probe(CommandSourceStack source) {
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("An operator player must run this command."));
return 0;
}
ServerLevel level = player.serverLevel();
AgeDefinition age = AgeRegistrySavedData.get(level)
.findByDimension(level.dimension().location()).orElse(null);
if (age == null) {
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
return 0;
}
AgeRuntimeProfile profile = age.plan() == null
? AgeRuntimeProfiles.resolve(age.generationProfile()) : age.plan().runtimeProfile();
if (AgeBiomeSelection.isCave(profile) || profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
source.sendFailure(Component.literal("This Age terrain does not generate surface libraries."));
return 0;
}
ChunkPos candidate = candidates(age.seed(), player.blockPosition(),
MystcraftLibraryFeature.spacing(age.seed())).getFirst();
try {
LevelChunk chunk = level.getChunk(candidate.x, candidate.z);
String outcome;
var chest = MystcraftLibraryFeature.libraryChest(chunk, age.seed());
if (chest.isPresent()) {
BlockPos arrival = MystcraftLibraryFeature.arrival(age.seed(), candidate, chest.orElseThrow().getY());
outcome = "library generated; arrival at " + arrival.getX() + ", "
+ arrival.getY() + ", " + arrival.getZ() + " is "
+ (safeArrival(level, arrival) ? "safe" : "blocked");
} else {
var check = MystcraftLibraryFeature.check(level, candidate, age.seed());
outcome = check.eligible()
? "site passes current placement rules, but no library exists (possibly generated by an older build)"
: "rejected: " + check.reason();
}
String message = "Library candidate chunk " + candidate.x + ", " + candidate.z
+ ": " + outcome + ". Probe generates this chunk if needed.";
source.sendSuccess(() -> Component.literal(message), false);
return 1;
} catch (RuntimeException exception) {
source.sendFailure(Component.literal("Library probe failed: " + exception.getMessage()));
return 0;
}
}
public static int repairStands(CommandSourceStack source) {
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("An operator player must run this command."));
return 0;
}
ServerLevel level = player.serverLevel();
AgeDefinition age = AgeRegistrySavedData.get(level)
.findByDimension(level.dimension().location()).orElse(null);
if (age == null) {
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
return 0;
}
int changed = MystcraftLibraryFeature.repairStandFacings(level,
level.getChunkAt(player.blockPosition()), age.seed());
if (changed < 0) {
source.sendFailure(Component.literal("Stand inside a generated library and run the command again."));
return 0;
}
source.sendSuccess(() -> Component.literal("Corrected " + changed
+ " library stand facings; pages were retained."), false);
return changed;
}
public static int start(CommandSourceStack source) {
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("An operator player must run this command."));
return 0;
}
ServerLevel level = player.serverLevel();
AgeDefinition age = AgeRegistrySavedData.get(level)
.findByDimension(level.dimension().location()).orElse(null);
if (age == null) {
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
return 0;
}
AgeRuntimeProfile profile = age.plan() == null
? AgeRuntimeProfiles.resolve(age.generationProfile()) : age.plan().runtimeProfile();
if (AgeBiomeSelection.isCave(profile) || profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
source.sendFailure(Component.literal("This Age terrain cannot generate surface libraries."));
return 0;
}
int spacing = MystcraftLibraryFeature.spacing(age.seed());
List<ChunkPos> candidates = candidates(age.seed(), player.blockPosition(), spacing).stream()
.filter(chunk -> level.getWorldBorder().isWithinBounds(new BlockPos(
chunk.getMinBlockX() + 8, player.getBlockY(), chunk.getMinBlockZ() + 8)))
.toList();
if (candidates.isEmpty()) {
source.sendFailure(Component.literal("No library candidates lie inside this Age's world border."));
return 0;
}
Search search = new Search(source.getServer(), player.getUUID(), level, age.seed(), spacing, candidates, 0);
ACTIVE.put(player.getUUID(), search);
QUEUE.addLast(search);
source.sendSuccess(() -> Component.literal("Searching up to " + candidates.size()
+ " nearby library candidate chunks. One will be checked per tick; the search may generate chunks."), false);
return 1;
}
static List<ChunkPos> candidates(long seed, BlockPos origin) {
return candidates(seed, origin, MystcraftLibraryFeature.REGION);
}
static List<ChunkPos> candidates(long seed, BlockPos origin, int spacing) {
int regionX = Math.floorDiv(origin.getX() >> 4, MystcraftLibraryFeature.REGION);
int regionZ = Math.floorDiv(origin.getZ() >> 4, MystcraftLibraryFeature.REGION);
LinkedHashSet<ChunkPos> sites = new LinkedHashSet<>();
for (int dx = -REGION_RADIUS; dx <= REGION_RADIUS; dx++)
for (int dz = -REGION_RADIUS; dz <= REGION_RADIUS; dz++)
sites.add(MystcraftLibraryFeature.candidateChunk(seed, regionX + dx, regionZ + dz));
if (spacing != MystcraftLibraryFeature.REGION) {
int testRegionX = Math.floorDiv(origin.getX() >> 4, spacing);
int testRegionZ = Math.floorDiv(origin.getZ() >> 4, spacing);
for (int dx = -REGION_RADIUS; dx <= REGION_RADIUS; dx++)
for (int dz = -REGION_RADIUS; dz <= REGION_RADIUS; dz++)
sites.add(MystcraftLibraryFeature.candidateChunk(seed, testRegionX + dx, testRegionZ + dz, spacing));
}
List<ChunkPos> choices = new ArrayList<>(sites);
choices.sort(Comparator.comparingLong(chunk -> {
long x = (long) chunk.getMiddleBlockX() - origin.getX();
long z = (long) chunk.getMiddleBlockZ() - origin.getZ();
return x * x + z * z;
}));
return List.copyOf(choices.subList(0, Math.min(MAX_CANDIDATES, choices.size())));
}
public static void onServerTickPost(ServerTickEvent.Post event) {
if (QUEUE.isEmpty()) return;
Search search = QUEUE.pollFirst();
if (search.server() != event.getServer() || ACTIVE.get(search.playerId()) != search) return;
ServerPlayer player = search.server().getPlayerList().getPlayer(search.playerId());
if (player == null || player.serverLevel() != search.level()) {
ACTIVE.remove(search.playerId());
return;
}
if (MystcraftLibraryFeature.spacing(search.seed()) != search.spacing()) {
ACTIVE.remove(search.playerId());
player.sendSystemMessage(Component.literal("Library search stopped because test spacing changed. Run nearest again."));
return;
}
ChunkPos candidate = search.candidates().get(search.next());
try {
var generated = search.level().getChunkSource().getChunk(candidate.x, candidate.z,
ChunkStatus.FULL, true);
if (generated instanceof LevelChunk chunk) {
var chest = MystcraftLibraryFeature.libraryChest(chunk, search.seed());
if (chest.isPresent()) {
BlockPos arrival = MystcraftLibraryFeature.arrival(search.seed(), candidate, chest.orElseThrow().getY());
if (safeArrival(search.level(), arrival)) {
ACTIVE.remove(search.playerId());
player.teleportTo(search.level(), arrival.getX() + 0.5D, arrival.getY(),
arrival.getZ() + 0.5D, player.getYRot(), player.getXRot());
player.fallDistance = 0;
player.sendSystemMessage(Component.literal("Teleported to the nearest found library at "
+ arrival.getX() + ", " + arrival.getY() + ", " + arrival.getZ() + "."));
return;
}
}
}
} catch (RuntimeException exception) {
ACTIVE.remove(search.playerId());
player.sendSystemMessage(Component.literal("Library search stopped: " + exception.getMessage()));
return;
}
if (search.next() + 1 >= search.candidates().size()) {
ACTIVE.remove(search.playerId());
player.sendSystemMessage(Component.literal("No intact library found among "
+ search.candidates().size() + " nearby candidates. Explore farther and try again."));
} else {
if ((search.next() + 1) % 32 == 0)
player.sendSystemMessage(Component.literal("Library search: checked "
+ (search.next() + 1) + "/" + search.candidates().size() + " candidates."));
Search advanced = search.advance();
ACTIVE.put(search.playerId(), advanced);
QUEUE.addLast(advanced);
}
}
private static boolean safeArrival(ServerLevel level, BlockPos pos) {
return level.isInWorldBounds(pos) && level.getBlockState(pos.below()).isFaceSturdy(
level, pos.below(), net.minecraft.core.Direction.UP)
&& level.getBlockState(pos).getCollisionShape(level, pos).isEmpty()
&& level.getBlockState(pos.above()).getCollisionShape(level, pos.above()).isEmpty()
&& level.getBlockState(pos).getFluidState().isEmpty();
}
public static void onServerStopping(ServerStoppingEvent event) {
QUEUE.removeIf(search -> search.server() == event.getServer());
ACTIVE.entrySet().removeIf(entry -> entry.getValue().server() == event.getServer());
MystcraftLibraryFeature.clearTestSpacing();
}
}
@@ -29,6 +29,22 @@ public final class RivencraftCommands {
public static void register(RegisterCommandsEvent event) {
event.getDispatcher().register(Commands.literal("rivencraft")
.requires(source -> source.hasPermission(2))
.then(Commands.literal("library")
.then(Commands.literal("nearest")
.executes(context -> LibraryLocateService.start(context.getSource())))
.then(Commands.literal("test-place")
.executes(context -> LibraryLocateService.testPlace(context.getSource())))
.then(Commands.literal("probe")
.executes(context -> LibraryLocateService.probe(context.getSource())))
.then(Commands.literal("repair-stands")
.executes(context -> LibraryLocateService.repairStands(context.getSource())))
.then(Commands.literal("test-spacing")
.executes(context -> LibraryLocateService.testSpacing(context.getSource(), null))
.then(Commands.literal("reset")
.executes(context -> LibraryLocateService.testSpacing(context.getSource(), 32)))
.then(Commands.argument("chunks", IntegerArgumentType.integer(1, 31))
.executes(context -> LibraryLocateService.testSpacing(context.getSource(),
IntegerArgumentType.getInteger(context, "chunks"))))))
.then(Commands.literal("age")
.then(Commands.literal("list")
.executes(context -> listAges(context.getSource())))
@@ -6,6 +6,8 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
import net.dnorberg.rivencraft.book.AgeTraits;
import net.dnorberg.rivencraft.world.MystcraftLibraryFeature;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.neoforged.neoforge.gametest.GameTestHolder;
@@ -43,6 +45,38 @@ public final class RivencraftCommandsGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void nearestLibraryCandidatesAreBoundedAndOrdered(GameTestHelper helper) {
BlockPos origin = new BlockPos(-400, 74, 900);
List<net.minecraft.world.level.ChunkPos> candidates = LibraryLocateService.candidates(7301L, origin);
helper.assertValueEqual(candidates.size(), 128, "Library lookup must have a finite chunk budget");
long previous = -1;
java.util.Set<net.minecraft.world.level.ChunkPos> unique = new java.util.HashSet<>();
for (var candidate : candidates) {
helper.assertTrue(unique.add(candidate), "A candidate chunk must not be searched twice");
long dx = (long) candidate.getMiddleBlockX() - origin.getX();
long dz = (long) candidate.getMiddleBlockZ() - origin.getZ();
long distance = dx * dx + dz * dz;
helper.assertTrue(distance >= previous, "Nearest candidates must be tested first");
previous = distance;
}
helper.succeed();
}
@GameTest(template = "empty")
public static void testSpacingAddsNearbyCandidatesWithoutRemovingNormalSites(GameTestHelper helper) {
BlockPos origin = new BlockPos(0, 80, 0);
long seed = 7302L;
var normal = LibraryLocateService.candidates(seed, origin);
var dense = LibraryLocateService.candidates(seed, origin, 8);
helper.assertValueEqual(dense.size(), 128, "Dense lookup must still use the bounded search budget");
helper.assertTrue(dense.stream().anyMatch(chunk -> !normal.contains(chunk)),
"Test spacing must add additional nearby candidates");
helper.assertTrue(dense.contains(MystcraftLibraryFeature.candidateChunk(seed, 0, 0, 8)),
"Nearest test site should be searched");
helper.succeed();
}
private static AgeDefinition age(String name, String path) {
UUID id = UUID.randomUUID();
return new AgeDefinition(id, name, 1L, AgeGenerationProfiles.STANDARD,
@@ -8,6 +8,7 @@ import net.neoforged.neoforge.common.ModConfigSpec;
/** Server-configurable gameplay policies. */
public final class RivencraftConfig {
public enum LibraryLootMode { LEGACY, AGE_ONLY }
public enum ReturnLinkMode {
/** Create one Return Link when an Age is first reached. */
ON_CREATION,
@@ -19,6 +20,7 @@ public final class RivencraftConfig {
public static final ModConfigSpec SPEC;
private static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
private static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
@@ -75,6 +77,12 @@ public final class RivencraftConfig {
)
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
builder.pop();
builder.push("libraries");
LIBRARY_LOOT_MODE = builder.comment(
"LEGACY gives libraries varied writing supplies and random symbol pages; AGE_ONLY gives only symbol pages used by this Age.",
"Applies to newly generated libraries only; existing chests and stands keep their contents.")
.defineEnum("loot_mode", LibraryLootMode.LEGACY);
builder.pop();
builder.push("writing");
SYMBOL_INK_COST = builder.comment("Ink units consumed when the Writing Desk copies one symbol page.")
.defineInRange("symbol_ink_cost", 1, 1, 16);
@@ -229,6 +237,8 @@ public final class RivencraftConfig {
return AUTO_WHITELIST_SERENE_AGES.get();
}
public static LibraryLootMode libraryLootMode() { return LIBRARY_LOOT_MODE.get(); }
public static boolean instabilityEnabled() { return INSTABILITY_ENABLED.get(); }
public static int instabilityDifficulty() { return INSTABILITY_DIFFICULTY.get(); }
public static boolean instabilityProviderEnabled(ResourceLocation id) {
@@ -19,7 +19,8 @@ public final class BookStandConversionHandler {
public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
ItemStack heldBook = event.getItemStack();
if (!heldBook.is(ModItems.LINKING_BOOK.get()) && !heldBook.is(ModItems.DESCRIPTIVE_BOOK.get())) {
if (!heldBook.is(ModItems.LINKING_BOOK.get()) && !heldBook.is(ModItems.DESCRIPTIVE_BOOK.get())
&& !heldBook.is(ModItems.PAGE.get())) {
return;
}
@@ -60,7 +60,9 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed),
Codec.BOOL.optionalFieldOf("nether_cave_materials", false).forGetter(generator -> generator.netherCaveMaterials),
ResourceLocation.CODEC.listOf().optionalFieldOf("nether_biomes", List.of())
.forGetter(generator -> generator.netherBiomeIds))
.forGetter(generator -> generator.netherBiomeIds),
ResourceLocation.CODEC.listOf().optionalFieldOf("library_age_symbols", List.of())
.forGetter(generator -> generator.libraryAgeSymbols))
.apply(instance, AgeFeatureChunkGenerator::new));
private final Holder<NoiseGeneratorSettings> settings;
@@ -68,16 +70,25 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
private final long ageSeed;
private final boolean netherCaveMaterials;
private final List<ResourceLocation> netherBiomeIds;
private final List<ResourceLocation> libraryAgeSymbols;
public AgeFeatureChunkGenerator(BiomeSource biomes, Holder<NoiseGeneratorSettings> settings,
List<AgeFeatureSelection.Choice> features, long ageSeed,
boolean netherCaveMaterials, List<ResourceLocation> netherBiomeIds) {
this(biomes, settings, features, ageSeed, netherCaveMaterials, netherBiomeIds, List.of());
}
public AgeFeatureChunkGenerator(BiomeSource biomes, Holder<NoiseGeneratorSettings> settings,
List<AgeFeatureSelection.Choice> features, long ageSeed,
boolean netherCaveMaterials, List<ResourceLocation> netherBiomeIds,
List<ResourceLocation> libraryAgeSymbols) {
super(biomes, settings);
this.settings = settings;
this.features = List.copyOf(features);
this.ageSeed = ageSeed;
this.netherCaveMaterials = netherCaveMaterials;
this.netherBiomeIds = List.copyOf(netherBiomeIds);
this.libraryAgeSymbols = List.copyOf(libraryAgeSymbols);
}
public List<AgeFeatureSelection.Choice> ageFeatures() { return features; }
@@ -253,5 +264,8 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
^ (long) occurrence * 0xD1B54A32D192ED03L;
authored.place(level, this, RandomSource.create(seed), origin);
}
// Place after vanilla and authored decoration so later features cannot grow through
// the building; the library check preserves existing solid structures.
if (!netherCaveMaterials) MystcraftLibraryFeature.generate(level, chunk, ageSeed, libraryAgeSymbols);
}
}
@@ -42,12 +42,15 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
FlatLevelGeneratorSettings.CODEC.fieldOf("settings").forGetter(AgeFlatChunkGenerator::settings),
AgeFeatureSelection.Choice.CODEC.listOf().optionalFieldOf("age_features", List.of())
.forGetter(generator -> generator.features),
Codec.LONG.optionalFieldOf("age_seed", 0L).forGetter(generator -> generator.ageSeed))
Codec.LONG.optionalFieldOf("age_seed", 0L).forGetter(generator -> generator.ageSeed),
ResourceLocation.CODEC.listOf().optionalFieldOf("library_age_symbols", List.of())
.forGetter(generator -> generator.libraryAgeSymbols))
.apply(instance, AgeFlatChunkGenerator::new));
private final FlatLevelGeneratorSettings settings;
private final List<AgeFeatureSelection.Choice> features;
private final long ageSeed;
private final List<ResourceLocation> libraryAgeSymbols;
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings) {
this(biomeSource, settings, List.of(), 0L);
@@ -55,10 +58,17 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings,
List<AgeFeatureSelection.Choice> features, long ageSeed) {
this(biomeSource, settings, features, ageSeed, List.of());
}
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings,
List<AgeFeatureSelection.Choice> features, long ageSeed,
List<ResourceLocation> libraryAgeSymbols) {
super(biomeSource, Util.memoize(settings::adjustGenerationSettings));
this.settings = settings;
this.features = List.copyOf(features);
this.ageSeed = ageSeed;
this.libraryAgeSymbols = List.copyOf(libraryAgeSymbols);
}
public FlatLevelGeneratorSettings settings() { return settings; }
@@ -133,6 +143,7 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
if (AgeFeatureSelection.obelisks(choice.id()))
ObelisksFeature.generate(level, chunk, ageSeed, choice, obeliskOccurrence++);
}
MystcraftLibraryFeature.generate(level, chunk, ageSeed, libraryAgeSymbols);
}
@Override
@@ -0,0 +1,369 @@
package net.dnorberg.rivencraft.world;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.tags.BlockTags;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.LegacyRandomSource;
import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Sparse, page-free Age library. Its entire footprint lives in its candidate chunk. */
public final class MystcraftLibraryFeature {
public static final int REGION = 32;
static final int FOOTPRINT = 11;
private static final int OFFSET_BOUND = 24;
private static final int MAX_SUPPORT_DEPTH = 24;
private static final int SALT = 14357617;
private static final int TEST_SALT = 0x4C494252;
private static final String CHEST_MARKER = "RivencraftLibrary";
private static final ConcurrentHashMap<Long, Integer> TEST_SPACING = new ConcurrentHashMap<>();
// x, y, z, clockwise turns relative to the left stand. The middle row
// faces across the room; the far stand faces back toward the entrance.
private static final int[][] STANDS = {{6, 1, 2, 0}, {8, 1, 4, 1}, {8, 1, 5, 1},
{8, 1, 6, 1}, {6, 1, 8, 2}};
private static final ResourceLocation TEMPLATE = ResourceLocation.fromNamespaceAndPath(
"rivencraft", "mystcraft_library");
private static final ResourceKey<LootTable> CHEST_LOOT = ResourceKey.create(Registries.LOOT_TABLE,
ResourceLocation.fromNamespaceAndPath("rivencraft", "chests/mystcraft_library"));
private MystcraftLibraryFeature() {}
record Site(int x, int z, int rotation) {}
public static int spacing(long seed) {
return TEST_SPACING.getOrDefault(seed, REGION);
}
/** Session-only testing override; normal 32-chunk sites always remain eligible. */
public static void setTestSpacing(long seed, int chunks) {
if (chunks < 1 || chunks > REGION) throw new IllegalArgumentException("Invalid library spacing " + chunks);
if (chunks == REGION) TEST_SPACING.remove(seed);
else TEST_SPACING.put(seed, chunks);
}
public static void clearTestSpacing() {
TEST_SPACING.clear();
}
/** The single deterministic candidate in one Legacy-sized region. */
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ) {
return candidateChunk(seed, regionX, regionZ, REGION);
}
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ, int spacing) {
if (spacing < 1 || spacing > REGION) throw new IllegalArgumentException("Invalid library spacing " + spacing);
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
random.setLargeFeatureWithSalt(seed, regionX, regionZ,
spacing == REGION ? SALT : TEST_SALT ^ spacing);
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
return new ChunkPos(regionX * spacing + random.nextInt(bound),
regionZ * spacing + random.nextInt(bound));
}
static Site site(long seed, ChunkPos chunk) {
Site normal = site(seed, chunk, REGION);
return normal != null ? normal : spacing(seed) == REGION ? null : site(seed, chunk, spacing(seed));
}
private static Site site(long seed, ChunkPos chunk, int spacing) {
int rx = Math.floorDiv(chunk.x, spacing);
int rz = Math.floorDiv(chunk.z, spacing);
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
random.setLargeFeatureWithSalt(seed, rx, rz,
spacing == REGION ? SALT : TEST_SALT ^ spacing);
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
int candidateX = rx * spacing + random.nextInt(bound);
int candidateZ = rz * spacing + random.nextInt(bound);
if (chunk.x != candidateX || chunk.z != candidateZ) return null;
return new Site(chunk.getMinBlockX() + 2, chunk.getMinBlockZ() + 2, random.nextInt(4));
}
/** A saved marker distinguishes a generated library from player-built chests. */
public static Optional<BlockPos> libraryChest(LevelChunk chunk) {
return chunk.getBlockEntities().entrySet().stream()
.filter(entry -> entry.getValue() instanceof ChestBlockEntity chest
&& chest.getPersistentData().getBoolean(CHEST_MARKER))
.map(java.util.Map.Entry::getKey).findFirst();
}
/** Recognize libraries generated before the chest marker was introduced. */
public static Optional<BlockPos> libraryChest(LevelChunk chunk, long seed) {
Optional<BlockPos> marked = libraryChest(chunk);
if (marked.isPresent()) return marked;
Site site = site(seed, chunk.getPos());
if (site == null) return Optional.empty();
for (var entry : chunk.getBlockEntities().entrySet()) {
if (!(entry.getValue() instanceof ChestBlockEntity chest)) continue;
int y = entry.getKey().getY();
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
if (!entry.getKey().equals(mapped(site, cursor, 4, y, 2))
|| !chunk.getBlockState(mapped(site, cursor, 2, y, 2)).is(Blocks.BOOKSHELF)
|| !chunk.getBlockState(mapped(site, cursor, 2, y + 1, 2)).is(Blocks.BOOKSHELF)
|| !chunk.getBlockState(mapped(site, cursor, 4, y + 6, 2)).is(Blocks.OAK_PLANKS))
continue;
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
chest.setChanged();
return Optional.of(entry.getKey());
}
return Optional.empty();
}
/** Open interior aisle, directly above the cobblestone floor. */
public static BlockPos arrival(long seed, ChunkPos chunk, int chestY) {
Site site = site(seed, chunk);
if (site == null) throw new IllegalArgumentException("Not a library candidate chunk: " + chunk);
return mapped(site, new BlockPos.MutableBlockPos(), 5, chestY, 4).immutable();
}
public record PlacementCheck(String reason, int floorY, int[][] heights) {
public boolean eligible() { return reason == null; }
}
/** Inspect one candidate without placing blocks. The same test gates natural placement. */
public static PlacementCheck check(WorldGenLevel level, ChunkPos chunk, long seed) {
Site site = site(seed, chunk);
if (site == null) return rejected("not a candidate chunk");
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
int low = Integer.MAX_VALUE;
int high = Integer.MIN_VALUE;
int[][] heights = new int[FOOTPRINT][FOOTPRINT];
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
int wx = site.x() + x;
int wz = site.z() + z;
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, wx, wz);
// The heightmap includes tree canopies. Libraries sit on terrain, not leaves.
while (y > level.getMinBuildHeight() + 2) {
BlockState below = level.getBlockState(cursor.set(wx, y - 1, wz));
if (!below.isAir() && !isVegetation(below)) break;
y--;
}
if (y < level.getMinBuildHeight() + 2) return rejected("no surface footing");
BlockState ground = level.getBlockState(cursor.set(wx, y - 1, wz));
if (!ground.getFluidState().isEmpty()) return rejected("fluid at ground level");
if (ground.hasBlockEntity() || !ground.isFaceSturdy(level, cursor, Direction.UP))
return rejected("ground is not sturdy terrain");
heights[x][z] = y;
low = Math.min(low, y);
high = Math.max(high, y);
}
if (high + 10 >= level.getMaxBuildHeight()) return rejected("insufficient build height");
if (high - low > MAX_SUPPORT_DEPTH) return rejected("slope exceeds support depth ("
+ (high - low) + " > " + MAX_SUPPORT_DEPTH + ")");
// Check the entire raised foundation and house. Foliage can be cleared, but not
// structures, fluids, or block entities already occupying this volume.
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
for (int y = heights[x][z]; y <= high + 10; y++) {
BlockState state = level.getBlockState(cursor.set(site.x() + x, y, site.z() + z));
if (!state.getFluidState().isEmpty()) return rejected("fluid inside footprint");
if (state.hasBlockEntity()) return rejected("block entity inside footprint");
if (!(state.isAir() || state.canBeReplaced() || isVegetation(state)))
return rejected("solid obstruction inside footprint: "
+ state.getBlock().getDescriptionId());
}
return new PlacementCheck(null, high, heights);
}
private static PlacementCheck rejected(String reason) {
return new PlacementCheck(reason, 0, null);
}
private static boolean isVegetation(BlockState state) {
return state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS)
|| state.is(BlockTags.FLOWERS) || state.is(BlockTags.SAPLINGS)
|| !state.isAir() && state.canBeReplaced() && state.getFluidState().isEmpty();
}
static boolean generate(WorldGenLevel level, ChunkAccess chunk, long seed,
List<ResourceLocation> ageSymbols) {
Site site = site(seed, chunk.getPos());
if (site == null) return false;
PlacementCheck check = check(level, chunk.getPos(), seed);
if (!check.eligible()) return false;
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
for (int y = check.heights()[x][z]; y <= check.floorY() + 10; y++) {
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
if (isVegetation(level.getBlockState(pos))) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 2);
}
return build(level, site, check.floorY(), check.heights(), seed, ageSymbols);
}
/** Explicit OP testing placement. Never overwrites existing blocks or entities. */
public static Optional<BlockPos> testPlace(ServerLevel level, BlockPos playerPos, Direction facing,
long seed, List<ResourceLocation> ageSymbols) {
Direction horizontal = facing.getAxis().isHorizontal() ? facing : Direction.NORTH;
BlockPos center = playerPos.relative(horizontal, 13);
int rotation = switch (horizontal) {
case NORTH -> 2;
case EAST -> 3;
case WEST -> 1;
default -> 0;
};
Site site = new Site(center.getX() - FOOTPRINT / 2, center.getZ() - FOOTPRINT / 2, rotation);
int floorY = playerPos.getY() + 2;
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
floorY = Math.max(floorY, level.getHeight(Heightmap.Types.WORLD_SURFACE,
site.x() + x, site.z() + z) + 1);
if (floorY + 10 >= level.getMaxBuildHeight() || floorY - playerPos.getY() > 20)
return Optional.empty();
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
for (int y = floorY; y <= floorY + 10; y++) {
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
if (!level.getWorldBorder().isWithinBounds(pos) || !level.getBlockState(pos).isAir())
return Optional.empty();
}
if (!level.getEntities(null, new AABB(site.x(), floorY, site.z(),
site.x() + FOOTPRINT, floorY + 11, site.z() + FOOTPRINT)).isEmpty())
return Optional.empty();
if (!build(level, site, floorY, null, seed, ageSymbols)) return Optional.empty();
return Optional.of(mapped(site, cursor, 4, floorY + 1, 2).immutable());
}
private static boolean build(WorldGenLevel level, Site site, int floorY, int[][] heights,
long seed, List<ResourceLocation> ageSymbols) {
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
var template = level.getLevel().getStructureManager().get(TEMPLATE).orElse(null);
if (template == null || template.getSize().getX() != FOOTPRINT
|| template.getSize().getY() != FOOTPRINT || template.getSize().getZ() != FOOTPRINT)
return false;
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
for (int y = heights == null ? floorY : heights[x][z] - 1; y <= floorY; y++)
place(level, site, cursor, x, y, z, Blocks.COBBLESTONE.defaultBlockState());
}
Rotation rotation = switch (site.rotation()) {
case 1 -> Rotation.CLOCKWISE_90;
case 2 -> Rotation.CLOCKWISE_180;
case 3 -> Rotation.COUNTERCLOCKWISE_90;
default -> Rotation.NONE;
};
BlockPos origin = new BlockPos(site.x(), floorY, site.z());
var settings = new StructurePlaceSettings().setRotation(rotation)
.setRotationPivot(new BlockPos(5, 0, 5)).setIgnoreEntities(true);
long positionSalt = new ChunkPos(site.x() >> 4, site.z() >> 4).toLong();
if (!template.placeInWorld(level, origin, origin, settings,
RandomSource.create(seed ^ positionSalt), 2)) return false;
BlockPos chestPos = mapped(site, cursor, 4, floorY + 1, 2).immutable();
level.setBlock(chestPos, Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING,
Direction.from2DDataValue(site.rotation())), 2);
RivencraftConfig.LibraryLootMode mode = RivencraftConfig.libraryLootMode();
boolean ageOnly = mode == RivencraftConfig.LibraryLootMode.AGE_ONLY;
List<ResourceLocation> symbols = symbolsForLoot(ageSymbols, mode);
RandomSource random = RandomSource.create(seed ^ positionSalt ^ SALT);
if (level.getBlockEntity(chestPos) instanceof ChestBlockEntity chest) {
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
chest.setChanged();
if (ageOnly) {
// Pages are frozen in the Age generator, then saved as real chest items.
boolean[] occupied = new boolean[chest.getContainerSize()];
for (int i = 0, count = symbols.isEmpty() ? 0 : 4 + random.nextInt(5); i < count; i++) {
int slot;
do slot = random.nextInt(occupied.length); while (occupied[slot]);
occupied[slot] = true;
chest.setItem(slot, page(symbols, random));
}
} else {
chest.setLootTable(CHEST_LOOT, seed ^ chestPos.asLong());
}
}
for (int[] standCoords : STANDS) {
BlockPos pos = mapped(site, cursor, standCoords[0], floorY + standCoords[1], standCoords[2]).immutable();
if (!level.getBlockState(pos).isAir()) continue;
level.setBlock(pos, ModBlocks.BOOK_STAND.get().defaultBlockState()
.setValue(BookStandBlock.FACING, standFacing(site, standCoords[3]))
.setValue(BookStandBlock.HAS_BOOK, true), 2);
if (level.getBlockEntity(pos) instanceof BookStandBlockEntity stand) {
stand.setBook(symbols.isEmpty() ? new ItemStack(Items.BOOK) : page(symbols, random));
}
}
return true;
}
static Direction standFacing(Site site, int clockwiseTurns) {
return Direction.from2DDataValue((site.rotation() + clockwiseTurns) & 3);
}
/** Correct older generated stands in place, retaining their block entities and pages. */
public static int repairStandFacings(ServerLevel level, LevelChunk chunk, long seed) {
Site site = site(seed, chunk.getPos());
if (site == null || libraryChest(chunk, seed).isEmpty()) return -1;
int changed = 0;
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
int chestY = libraryChest(chunk, seed).orElseThrow().getY();
for (int[] standCoords : STANDS) {
BlockPos pos = mapped(site, cursor, standCoords[0], chestY, standCoords[2]);
BlockState state = level.getBlockState(pos);
if (!state.is(ModBlocks.BOOK_STAND.get())) continue;
Direction facing = standFacing(site, standCoords[3]);
if (state.getValue(BookStandBlock.FACING) != facing) {
level.setBlock(pos, state.setValue(BookStandBlock.FACING, facing), 3);
changed++;
}
}
return changed;
}
static List<ResourceLocation> symbolsForLoot(List<ResourceLocation> ageSymbols,
RivencraftConfig.LibraryLootMode mode) {
return mode == RivencraftConfig.LibraryLootMode.AGE_ONLY ? ageSymbols.stream()
.filter(id -> SymbolCatalog.isKnown(new SymbolReference(id))).distinct().toList()
: SymbolCatalog.symbols().entrySet().stream()
.filter(entry -> entry.getKey().getNamespace().equals("rivencraft")
&& entry.getValue().semantics().isPresent()
&& !entry.getKey().getPath().startsWith("block/")
&& !entry.getKey().getPath().startsWith("biome/"))
.map(java.util.Map.Entry::getKey).sorted().toList();
}
private static net.minecraft.world.item.ItemStack page(List<ResourceLocation> symbols, RandomSource random) {
SymbolReference reference = new SymbolReference(symbols.get(random.nextInt(symbols.size())));
var page = PageItem.createSymbolPage(reference);
page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), SymbolCatalog.display(reference));
return page;
}
private static BlockPos.MutableBlockPos mapped(Site site, BlockPos.MutableBlockPos cursor, int x, int y, int z) {
return switch (site.rotation()) {
case 1 -> cursor.set(site.x() + FOOTPRINT - 1 - z, y, site.z() + x);
case 2 -> cursor.set(site.x() + FOOTPRINT - 1 - x, y, site.z() + FOOTPRINT - 1 - z);
case 3 -> cursor.set(site.x() + z, y, site.z() + FOOTPRINT - 1 - x);
default -> cursor.set(site.x() + x, y, site.z() + z);
};
}
private static void place(WorldGenLevel level, Site site, BlockPos.MutableBlockPos cursor,
int x, int y, int z, BlockState state) {
level.setBlock(mapped(site, cursor, x, y, z), state, 2);
}
}
@@ -0,0 +1,162 @@
package net.dnorberg.rivencraft.world;
import java.util.List;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder("rivencraft_archive_generation")
@PrefixGameTestTemplate(false)
public final class MystcraftLibraryGameTests {
private MystcraftLibraryGameTests() {}
@GameTest(template = "empty")
public static void packagedLegacyLibraryTemplateHasCorrectLayout(GameTestHelper helper) {
var id = ResourceLocation.fromNamespaceAndPath("rivencraft", "mystcraft_library");
var template = helper.getLevel().getStructureManager().get(id).orElseThrow();
helper.assertValueEqual(template.getSize().getX(), 11, "Legacy library must be 11 blocks wide");
helper.assertValueEqual(template.getSize().getY(), 11, "Legacy library must reach its top slabs");
helper.assertValueEqual(template.getSize().getZ(), 11, "Legacy library must be 11 blocks deep");
var settings = new StructurePlaceSettings();
var shelves = template.filterBlocks(BlockPos.ZERO, settings, Blocks.BOOKSHELF);
helper.assertTrue(shelves.stream().anyMatch(block -> block.pos().equals(new BlockPos(4, 1, 3))),
"Legacy shelves must be present in the template");
helper.assertTrue(shelves.stream().noneMatch(block -> block.pos().equals(new BlockPos(6, 1, 2))),
"The old lower lectern block must be omitted for the Rivencraft stand");
var roof = template.filterBlocks(BlockPos.ZERO, settings, Blocks.COBBLESTONE_SLAB);
helper.assertTrue(roof.stream().anyMatch(block -> block.pos().equals(new BlockPos(5, 10, 5))),
"Legacy roof slab must be preserved");
helper.succeed();
}
@GameTest(template = "empty")
public static void librarySitesAreSparseStableAndSingleChunk(GameTestHelper helper) {
for (long seed : new long[]{0L, 87L, -99234L}) {
for (int rx : new int[]{-2, -1, 0, 3}) {
for (int rz : new int[]{-2, 0, 4}) {
int found = 0;
for (int x = rx * MystcraftLibraryFeature.REGION;
x < (rx + 1) * MystcraftLibraryFeature.REGION; x++) {
for (int z = rz * MystcraftLibraryFeature.REGION;
z < (rz + 1) * MystcraftLibraryFeature.REGION; z++) {
ChunkPos chunk = new ChunkPos(x, z);
MystcraftLibraryFeature.Site site = MystcraftLibraryFeature.site(seed, chunk);
helper.assertTrue(java.util.Objects.equals(
MystcraftLibraryFeature.site(seed, chunk), site),
"Library site must be deterministic across reloads");
if (site == null) continue;
found++;
helper.assertTrue(site.x() >= chunk.getMinBlockX()
&& site.x() + MystcraftLibraryFeature.FOOTPRINT - 1 <= chunk.getMaxBlockX()
&& site.z() >= chunk.getMinBlockZ()
&& site.z() + MystcraftLibraryFeature.FOOTPRINT - 1 <= chunk.getMaxBlockZ(),
"The complete rotated library must remain inside its candidate chunk");
}
}
helper.assertValueEqual(found, 1,
"Each 32x32 region must have exactly one candidate, including negative regions");
}
}
}
helper.succeed();
}
@GameTest(template = "empty")
public static void libraryStandFacingsRotateWithTheStructure(GameTestHelper helper) {
for (int rotation = 0; rotation < 4; rotation++) {
var site = new MystcraftLibraryFeature.Site(0, 0, rotation);
Direction left = Direction.from2DDataValue(rotation);
helper.assertValueEqual(MystcraftLibraryFeature.standFacing(site, 0), left,
"Left stand must retain the structure's base facing");
helper.assertValueEqual(MystcraftLibraryFeature.standFacing(site, 1), left.getClockWise(),
"Middle stands must turn clockwise with the structure");
helper.assertValueEqual(MystcraftLibraryFeature.standFacing(site, 2), left.getOpposite(),
"Far stand must face back toward the left stand");
}
helper.succeed();
}
@GameTest(template = "empty")
public static void ageOnlyLibraryPoolNeverAddsOtherPages(GameTestHelper helper) {
ResourceLocation terrain = ResourceLocation.parse("rivencraft:terrain/standard");
ResourceLocation feature = ResourceLocation.parse("rivencraft:feature/rocks");
ResourceLocation unknown = ResourceLocation.parse("rivencraft:not_a_real_page");
helper.assertTrue(!SymbolCatalog.symbols().isEmpty(), "Writing catalog must be loaded");
helper.assertValueEqual(MystcraftLibraryFeature.symbolsForLoot(
List.of(terrain, feature, terrain, unknown), RivencraftConfig.LibraryLootMode.AGE_ONLY),
List.of(terrain, feature), "Age-only loot must use only resolved current-Age symbols");
helper.assertTrue(MystcraftLibraryFeature.symbolsForLoot(
List.of(terrain), RivencraftConfig.LibraryLootMode.LEGACY).size() > 1,
"Legacy loot should be drawn from the wider writing catalog");
helper.succeed();
}
@GameTest(template = "empty")
public static void testSpacingIsSessionOnlyAndKeepsLegacySites(GameTestHelper helper) {
long seed = 881L;
ChunkPos normal = MystcraftLibraryFeature.candidateChunk(seed, 0, 0);
ChunkPos dense = MystcraftLibraryFeature.candidateChunk(seed, 1, -1, 8);
try {
MystcraftLibraryFeature.setTestSpacing(seed, 8);
helper.assertValueEqual(MystcraftLibraryFeature.spacing(seed), 8,
"Test override must use the requested spacing");
helper.assertTrue(MystcraftLibraryFeature.site(seed, normal) != null,
"Legacy sites remain eligible while testing");
helper.assertTrue(MystcraftLibraryFeature.site(seed, dense) != null,
"Dense candidates must become eligible while testing");
} finally {
MystcraftLibraryFeature.setTestSpacing(seed, MystcraftLibraryFeature.REGION);
}
helper.assertValueEqual(MystcraftLibraryFeature.spacing(seed), MystcraftLibraryFeature.REGION,
"Reset restores Legacy spacing");
helper.succeed();
}
@GameTest(template = "empty")
public static void libraryStandStoresAndReturnsSymbolPage(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var state = ModBlocks.BOOK_STAND.get().defaultBlockState().setValue(BookStandBlock.HAS_BOOK, true);
helper.getLevel().setBlock(pos, state, 3);
helper.assertTrue(helper.getLevel().getBlockEntity(pos) instanceof BookStandBlockEntity,
"Library stand must create a persistent book entity");
BookStandBlockEntity stand = (BookStandBlockEntity) helper.getLevel().getBlockEntity(pos);
var page = PageItem.createSymbolPage(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard")));
stand.setBook(page);
helper.assertTrue(stand.getBook().is(ModItems.PAGE.get()), "Stand must hold a symbol page");
helper.assertValueEqual(stand.takeBook(), page, "Page must be recoverable from its stand");
helper.succeed();
}
@GameTest(template = "empty")
public static void locatorRecognizesOnlyMarkedLibraryChests(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, Blocks.CHEST.defaultBlockState(), 3);
var chunk = (net.minecraft.world.level.chunk.LevelChunk) helper.getLevel().getChunk(pos);
helper.assertTrue(MystcraftLibraryFeature.libraryChest(chunk).isEmpty(),
"An ordinary player chest must not be mistaken for a library");
helper.assertTrue(helper.getLevel().getBlockEntity(pos) instanceof ChestBlockEntity,
"The test chest must have a block entity");
ChestBlockEntity chest = (ChestBlockEntity) helper.getLevel().getBlockEntity(pos);
chest.getPersistentData().putBoolean("RivencraftLibrary", true);
chest.setChanged();
helper.assertValueEqual(MystcraftLibraryFeature.libraryChest(chunk).orElseThrow(), pos,
"A marked library chest must be discoverable without loading another chunk");
helper.succeed();
}
}
@@ -0,0 +1,26 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": { "type": "minecraft:uniform", "min": 4, "max": 8 },
"entries": [
{ "type": "minecraft:item", "name": "minecraft:paper", "weight": 10,
"functions": [{ "function": "minecraft:set_count", "count": { "type": "minecraft:uniform", "min": 2, "max": 8 } }] },
{ "type": "minecraft:item", "name": "minecraft:leather", "weight": 5,
"functions": [{ "function": "minecraft:set_count", "count": { "type": "minecraft:uniform", "min": 1, "max": 3 } }] },
{ "type": "minecraft:item", "name": "rivencraft:ink_vial", "weight": 4 },
{ "type": "minecraft:item", "name": "minecraft:book", "weight": 3 },
{ "type": "minecraft:item", "name": "minecraft:experience_bottle", "weight": 1 }
]
},
{
"rolls": { "type": "minecraft:uniform", "min": 1, "max": 2 },
"entries": [
{ "type": "minecraft:item", "name": "rivencraft:ink_vial", "weight": 3 },
{ "type": "minecraft:item", "name": "minecraft:book", "weight": 2 },
{ "type": "minecraft:item", "name": "minecraft:experience_bottle", "weight": 1 }
]
}
],
"random_sequence": "rivencraft:chests/mystcraft_library"
}
@@ -15,7 +15,7 @@ loaderVersion="${loader_version_range}" #mandatory
license="${mod_license}"
# A URL to refer people to when problems occur with this mod
issueTrackerURL="https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/issues"
issueTrackerURL="https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues"
# A list of mods - how many allowed here is determined by the individual mod loader
[[mods]] #mandatory
@@ -33,7 +33,7 @@ displayName="${mod_name}" #mandatory
#updateJSONURL="https://change.me.example.invalid/updates.json" #optional
# A URL for the "homepage" for this mod, displayed in the mod UI
displayURL="https://gitea.tail180fb1.ts.net/dnorberg/rivencraft"
displayURL="https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft"
# A file name (in the root of the mod JAR) containing a logo for display
#logoFile="rivencraft.png" #optional