Compare commits

...
Author SHA1 Message Date
dnorberg 9ab8d3de4a Port Binder and Ink Mixer interactions to 26.3 alpha
Build / build (push) Canceled after 0s
2026-10-30 15:55:43 -04:00
dnorberg 56005ac512 Port crystal growth, pages, ink vials and link crafting to 26.3
Build / build (push) Canceled after 0s
2026-10-29 15:07:46 -04:00
dnorberg 9ff931afa9 Add 26.3 descriptive book binding and travel
Build / build (push) Canceled after 0s
2026-10-29 14:32:50 -04:00
dnorberg eda19de43c Prepare Minecraft 26.3 technical alpha 1.0.1.2-alpha.1
Build / build (push) Canceled after 0s
2026-10-29 14:25:39 -04:00
dnorberg 0fb2f7c1bb Add incomplete Minecraft 26.3 port prototype
Build / build (push) Canceled after 0s
2026-10-27 19:09:19 -04:00
dnorberg 4f07f675a4 Refresh public links and include development screenshots
Build / build (push) Canceled after 0s
2026-10-26 14:25:18 -04:00
dnorberg b8be99606f Add configurable random Page lists and safe fallbacks (#95) 2026-10-26 14:25:13 -04:00
dnorberg 98595c83ea Complete Milestone 7 acceptance and binder diagnostics (#84)
Build / build (push) Canceled after 0s
2026-10-22 22:32:12 -04:00
dnorberg e7e2cf7e5a Add Milestone 7 regression and documentation review (#83)
Build / build (push) Canceled after 0s
2026-10-22 21:37:50 -04:00
dnorberg 77f15f8599 Use number pages for celestial modifiers (#94)
Build / build (push) Canceled after 0s
2026-10-22 15:33:37 -04:00
dnorberg 2b2605b277 Add optional authored Ruins structures to Ages (#91)
Build / build (push) Canceled after 0s
2026-10-22 15:17:17 -04:00
dnorberg 2f1e6bd6f2 Clear book receptacle model when removing book
Build / build (push) Canceled after 0s
2026-10-22 03:51:42 -04:00
dnorberg 9d5ff2a2cb Complete authored sky colors and eclipses (#44)
Build / build (push) Canceled after 0s
2026-10-22 03:40:10 -04:00
dnorberg 3ac4c499ab Add authored Age skies and celestial pages (#44)
Build / build (push) Canceled after 0s
2026-10-21 23:56:58 -04:00
dnorberg 9044835ec5 Support horizontal portals and visible receptacle books (#93)
Build / build (push) Canceled after 0s
2026-10-21 23:02:32 -04:00
dnorberg 7ae41da23e Add Legacy-style symbol portfolios (#92)
Build / build (push) Canceled after 0s
2026-10-21 14:13:34 -04:00
dnorberg 9cb63e6994 Align Writing Desk numerals with Legacy glyphs (#43)
Build / build (push) Canceled after 0s
2026-10-21 14:01:02 -04:00
dnorberg 6337b5aa10 Restore Legacy link modifier travel behavior
Build / build (push) Canceled after 0s
2026-10-21 13:36:30 -04:00
dnorberg b9faa28dd5 Refine Cave terrain environment and spawning (#14)
Build / build (push) Canceled after 0s
2026-10-21 08:38:23 -04:00
dnorberg 09d2b0800c Complete Link Modifier controls and seed-driven Age generation (#90)
Build / build (push) Canceled after 0s
2026-10-21 08:11:26 -04:00
dnorberg ee905cab68 Add Legacy missing items and OP Link Modifier (#90)
Build / build (push) Canceled after 0s
2026-10-20 17:50:09 -04:00
dnorberg 5c08f391a2 Update optional Serene Seasons compatibility to 10.1.0.9
Build / build (push) Canceled after 0s
2026-10-20 17:33:07 -04:00
dnorberg b8d26a9036 Update optional JEI compatibility to 19.69.0.457
Build / build (push) Canceled after 0s
2026-10-20 17:12:21 -04:00
dnorberg d0af75239a Match Legacy handheld book drops with model entity (#85)
Build / build (push) Canceled after 0s
2026-10-19 16:41:07 -04:00
dnorberg 2e5d6e0427 Adopt NeoForge 21.1.264 after compatibility testing
Build / build (push) Canceled after 0s
2026-10-19 05:37:46 -04:00
dnorberg 54c10cca6c Default automatic Return Links to disabled and review compatibility
Build / build (push) Canceled after 0s
2026-10-19 05:21:00 -04:00
dnorberg c39ebd78ba Add Milestone 7 progression advancements
Build / build (push) Canceled after 0s
2026-10-19 04:57:15 -04:00
dnorberg 1ff62e9aaa Polish Milestone 7 models textures and link effects
Build / build (push) Canceled after 0s
2026-10-19 04:32:35 -04:00
dnorberg b154455563 Complete Milestone 7 crafting and portal guide (#79)
Build / build (push) Canceled after 0s
2026-10-19 04:01:03 -04:00
dnorberg 4a87c9fae6 Harden crystal portals across restart and protected travel
Build / build (push) Canceled after 0s
2026-10-18 23:17:16 -04:00
dnorberg 350d137d9e Render fallback crystal portals as two-sided surfaces
Build / build (push) Canceled after 0s
2026-10-18 00:22:56 -04:00
dnorberg 59bd1c7f6c Implement crystal portals with optional Immersive Portals view (#77)
Build / build (push) Canceled after 0s
2026-10-17 12:13:12 -04:00
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
dnorberg 44205f80e0 Complete instability regression slice and ordered modifiers (#73)
Build / build (push) Canceled after 0s
2026-10-12 14:47:15 -04:00
dnorberg 4f464dbe60 Extend Legacy instability parity and accelerated ages
Build / build (push) Canceled after 0s
2026-10-11 17:14:46 -04:00
dnorberg 783a33456d Added live instability for block generation
Build / build (push) Canceled after 0s
2026-10-10 15:16:08 -04:00
dnorberg 7b5597dd47 Expose instability controls and diagnostics (#72)
Build / build (push) Canceled after 0s
2026-10-10 14:39:38 -04:00
dnorberg 78a0f7ee51 Allow authored structures in mixed biomes and score overrides (#71)
Build / build (push) Canceled after 0s
2026-10-10 14:23:10 -04:00
dnorberg dd219bb58a Completed issue 71 and allowed village pages to be placed in illegal biomes adding +100 instability
Build / build (push) Canceled after 0s
2026-10-10 03:41:16 -04:00
dnorberg b48cc8a6ce Align instability documentation with current runtime (#74)
Build / build (push) Canceled after 0s
2026-10-10 02:07:04 -04:00
dnorberg 779699d7a9 Port bounded meteor, burning, and erosion effects (#70)
Build / build (push) Canceled after 0s
2026-10-09 14:59:08 -04:00
dnorberg 33b9bde302 Add Legacy-rate bounded Age lightning and explosions (#69)
Build / build (push) Canceled after 0s
2026-10-09 13:16:46 -04:00
dnorberg a9a8b3cdd5 Port bounded colored decay and align Legacy defaults (#68)
Build / build (push) Canceled after 0s
2026-10-09 12:56:30 -04:00
dnorberg 066ef2b758 Implement Legacy-inspired Age status instability effects (#67)
Build / build (push) Canceled after 0s
2026-10-09 04:13:55 -04:00
dnorberg 8b32735c2e Add bounded per-Age instability provider runtime (#66)
Build / build (push) Canceled after 0s
2026-10-09 01:56:39 -04:00
dnorberg 7a47e40e2d Freeze Age instability scores and classify authored writing (#64 #65)
Build / build (push) Canceled after 0s
2026-10-09 00:12:31 -04:00
dnorberg 86cda69d11 Audit Legacy instability and allow unstable supported writing
Build / build (push) Canceled after 0s
Persist repaired Age writing and provisional instability penalties, retain fatal diagnostics for unsupported content, and hide TerraBlender's deferred placeholder biome from creative and JEI.
2026-10-08 14:10:42 -04:00
dnorberg a03e005a03 Complete Milestone 5 acceptance and streamline tests and assets (#42)
Build / build (push) Canceled after 0s
2026-10-08 06:01:33 -04:00
dnorberg 32b676db30 Weight random Age choices like Legacy and expand regression coverage (#41)
Build / build (push) Canceled after 0s
2026-10-08 05:43:18 -04:00
dnorberg a287e092f5 Use Nether materials for Nether cave biomes (#59)
Build / build (push) Canceled after 0s
2026-10-08 02:29:59 -04:00
dnorberg 4f1d458897 Map Star Fissures to modern world minimum Y (#58)
Build / build (push) Canceled after 0s
2026-10-08 02:20:33 -04:00
dnorberg 0c89cfc464 Port Legacy-style Star Fissures age feature (#58)
Build / build (push) Canceled after 0s
2026-10-05 16:12:59 -04:00
dnorberg f439d1febc Port blue budding crystal formations (#57)
Build / build (push) Canceled after 0s
2026-10-05 13:09:47 -04:00
dnorberg 6dbe208c42 Port authored Obelisks age feature (#56)
Build / build (push) Canceled after 0s
2026-10-05 11:56:48 -04:00
dnorberg a9a6f7205d Add mod-aware Dense Ores age feature (#55)
Build / build (push) Canceled after 0s
2026-10-04 19:07:10 -04:00
dnorberg 98a959f621 Add authored Age structures and modern structure pages (#50-#54, #61, #62)
Build / build (push) Canceled after 0s
2026-10-04 17:14:29 -04:00
dnorberg a574008472 Port Spheres and Spikes features (#48, #49)
Build / build (push) Canceled after 0s
2026-10-04 16:24:03 -04:00
dnorberg 66b4c56eb6 Port Skylands terrain filter (#47)
Build / build (push) Canceled after 0s
2026-10-04 15:54:54 -04:00
dnorberg a9040e8712 Port Tendrils feature generation (#46)
Build / build (push) Canceled after 0s
Add the writable symbol, adapt Legacy's branching solid cave-node geometry across all terrain types, and test authored and random Age selection.
2026-10-04 10:31:44 -04:00
dnorberg bb88bad8a6 Add Floating Islands and material-driven Age features (#45)
Build / build (push) Canceled after 0s
Keep repeated feature and material pages in authored writing, vary their resolved materials, and expose Link Panel modifier variants in creative and JEI. Remove Descriptive Book writing notes and refine Void biome selection.
2026-10-04 10:12:00 -04:00
dnorberg 916755e810 Render authored Descriptive Books and persist random Age seasons (#40)
Build / build (push) Canceled after 0s
2026-10-04 02:40:47 -04:00
dnorberg 6452d2c324 Add optional Serene Seasons Age policies and seasonal pages (#38)
Build / build (push) Canceled after 0s
2026-10-04 01:58:33 -04:00
dnorberg 4541cbbba5 Add optional TerraBlender biome integration and cave biome pages (#37)
Build / build (push) Canceled after 0s
2026-10-04 01:10:24 -04:00
dnorberg 68db795216 Cover panel-only book modifiers with regression test
Build / build (push) Canceled after 0s
2026-10-03 07:46:49 -04:00
dnorberg b86e855907 Add Link Panel modifiers and legacy Ink Mixer presentation (#36)
Build / build (push) Canceled after 0s
2026-10-03 07:36:46 -04:00
dnorberg 6c3b4f1885 Apply authored feature and decoration pages to ages
Build / build (push) Canceled after 0s
2026-10-02 21:28:11 -04:00
dnorberg 84a1f32732 Organize creative pages and stabilize GameTests
Build / build (push) Canceled after 0s
2026-10-02 14:49:41 -04:00
dnorberg a7b2c24915 Apply authored Age environment symbols (#34)
Build / build (push) Canceled after 0s
2026-10-01 17:28:20 -04:00
dnorberg 050a9537e9 Apply authored biome distributions and expose biome pages (#33)
Build / build (push) Canceled after 0s
2026-10-01 16:29:01 -04:00
dnorberg e28079566b Implement terrain symbol generation profiles
Build / build (push) Canceled after 0s
2026-09-30 16:44:22 -04:00
dnorberg 28c2ca0db3 Persist authored Age generation plans (#31)
Build / build (push) Canceled after 0s
2026-09-30 16:17:25 -04:00
dnorberg 27abebe3aa Validate ordered Age writing and highlight Binder errors (#30)
Build / build (push) Canceled after 0s
2026-09-30 15:39:53 -04:00
dnorberg 5538ca6aa6 Render Legacy glyph pages and base-25 desk numerals
Build / build (push) Canceled after 0s
2026-09-30 13:20:56 -04:00
585 changed files with 29026 additions and 888 deletions

No files matched your search

+5 -3
View File
@@ -74,9 +74,11 @@ Or, with mise:
All required GameTests should pass before distributing a build.
GameTests use `build/gametest-run/world`, separate from the normal development
world. Before each run, Gradle removes that test world if it exceeds 512 MiB.
Override the limit with `-PgameTestMaxWorldMiB=NUMBER`. A caller-supplied
`-PgameTestRunDir` is never removed automatically.
world. Gradle starts each default run with a fresh world and removes it after
every run, whether tests pass or fail. The GameTest server always uses seed `0`,
so retries regenerate a clean world with that seed. Logs and configuration are kept.
A caller-supplied `-PgameTestRunDir`
is never removed automatically.
To run the suite in a fresh temporary directory instead, use:
+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
+93 -200
View File
@@ -1,224 +1,117 @@
# Rivencraft
Rivencraft is a clean, incremental port of the ideas and gameplay of
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
1.21.1 on NeoForge.
Write a book. Name an Age. Step into a world that did not exist before.
The bootstrap, books/linking, Ages, and writing-system milestones are complete.
The writing system uses a data-pack symbol catalog and persistent
ordered-writing model. The project currently provides functional Linking Books
and randomly seeded, persistent vanilla-like Ages created from page-free
Descriptive Books. Instability and full symbol-to-world grammar interpretation
have not been ported yet.
Rivencraft brings the books, writing stations, symbols, and unpredictable Ages
of [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to
Minecraft 1.21.1 on NeoForge. Mystcraft veterans will recognize the writing
flow, D'ni-inspired glyphs, libraries, crystals, and instability. Rivencraft
also lets you author modern terrain, biomes, structures, seasons, and celestial
bodies. It is an active pre-public-release project, not a byte-for-byte copy of
the 1.12.2 mod.
Milestone 4 now includes stackable blank Pages plus server-created symbol,
unresolved-symbol, and baseline link-panel states. Page visuals follow the
legacy runtime-composition rules. Craft an Ink Vial from two black dye, a glass
bottle, and a water bucket; the bucket is returned. Hold vanilla paper in one
hand and use the Ink Vial in the other to prepare a baseline Link Panel Page.
Each vial holds 16 ink units, and the default link-panel cost is two units.
The spent vial returns a glass bottle. Server owners can adjust the symbol-copy
and link-panel costs in the `writing` section of `rivencraft-server.toml`.
The Ink Mixer now offers a Legacy-style station for producing Link Panels:
insert paper and Ink Vials, then take a Link Panel from the result slot. Each
vial fills its 16-unit basin and returns a glass bottle; no server tick is
needed. The earlier paper-in-one-hand method remains available. Ink modifier
ingredients and link properties are planned for Milestone 5.
The Writing Desk can copy a selected written page from its collection tabs.
The Writing Desk is now craftable from planks, a glass bottle, and a feather.
It occupies two adjacent blocks, with one shared inventory; loaded paper and
the selected page appear on its Legacy-inspired model.
Desks placed before 0.3.6.1 keep their old one-block footprint until broken
and placed again; leave an adjacent space when replacing one.
Its 88 collection tabs, paper input, ink vial input, empty glass-bottle return,
16-unit ink meter, and inventory persist
through world saves; breaking it drops stored contents. The desk screen is a
functional foundation using Legacy's original desk and scrollbar textures.
Four tabs are visible at once; the controls above and below them move by four
(1–4, 5–8, through 85–88). Each viewer can browse a different group. Existing
four-tab desks retain their collections and supplies when upgraded.
With a notebook selected, pick up a Page from your inventory and click an empty
position on the left surface to store one page there. Clear search/filter/sort
and turn off `ALL` to place pages at specific positions; occupied positions are
never overwritten. A notebook tab exposes all 27 notebook positions (scroll to
reach the rest); a loose-page tab exposes only its one occupied position.
Empty tabs have no page storage until a notebook or page is put in the tab slot.
Click a page on the desk surface to select it for copying. Drag a page from one
notebook position to an empty position to move it, or onto another page to swap
their positions. This also works between notebooks shown in the `ALL` view.
Drag a page out of the panel to turn it back into an item. Drop it over a player
inventory slot or desk tab to place it there; a tab with a notebook adds it to
the first open position. Dropping elsewhere leaves the page on the cursor for
normal inventory placement. The source notebook or tab loses the page only after
a valid drop.
Put vanilla paper in the
upper-left slot and Ink Vials in the upper-right; a copy appears in the
lower-left result slot when the selected page is valid. Taking it consumes one
paper and, by default, one ink. An available vial refills an empty ink meter
when inserted or when copying uses its last ink, and returns a vanilla glass
bottle to the lower-right slot. Removing a bottle also retries a blocked refill.
Refilling waits if that slot is full or contains another item. The `AZ` control sorts
visible pages by name; `ALL` shows pages held across every desk tab. Search and
category filtering narrow the view without granting new pages, and cross-tab
copy selection is checked by the server.
Selecting a stored page on the desk surface shows its Legacy-proportioned
portrait preview and name above the player inventory.
## Getting started
## Book Binder
1. Make an Ink Vial from two black dyes, a glass bottle, and a water bucket.
Craft an Ink Mixer, Writing Desk, and Book Binder.
2. Put paper and an Ink Vial in the Ink Mixer and take a Link Panel Page. Add
ingredients to the basin first if you want a chance of link modifiers.
3. Put leather or an empty Notebook and the Link Panel in the Book Binder.
Name the Age and take the Descriptive Book. A book with only a Link Panel
creates a seeded random Age; add Symbol Pages to author its features.
4. Open the book to create its Age, then click the destination preview to
travel. Bring a bound Linking Book for the return trip: automatic Return
Links are off by default.
For a new survival Age: craft an Ink Vial, an Ink Mixer, a Writing Desk, and a Book Binder.
Put vanilla paper and an Ink Vial in the Ink Mixer to make a Link Panel
Page. Place leather (or an empty Notebook) and the Link Panel in the Book Binder,
enter an Age name, and take the finished Descriptive Book. Written symbol Pages
are optional; without them, the book creates a random Age. To duplicate a Page
you already own, store it in a Writing Desk tab, select it, supply paper and ink,
and take the copied Page from the output. A Notebook can hold and transfer a
larger ordered collection of Pages between desks and the binder.
Find new Symbol Pages in Age libraries, or copy pages you already own with
paper and ink at the Writing Desk. The Notebook stores ordered pages, including
intentional gaps. Written pages can control terrain, biome distribution,
weather, lighting, seasons, sky color, suns and moons, decorations, structures,
and more. Incorrect or excessive writing can make an Age unstable; instability
may bring decay, lightning, explosions, meteors, burning, erosion, and other
hazards. Some combinations are still unsupported and the Binder reports them
before consuming ingredients.
With JEI installed, Rivencraft displays the Ink Mixer operation in its own
category and the hand-use, copying, binding, and Linking Book crafting flows in a Writing category.
JEI is optional; vanilla crafting
recipes and all writing interactions work without it. For a development client
with JEI, run `./gradlew runClient -PwithJei=true`.
Place a Rivencraft book or page on a vanilla Lectern to convert it to a Book
Stand. Crystal formations yield shards for Crystal Blocks. A connected crystal
frame with a Book Receptacle and a bound book forms a portal; frames can stand
upright or lie flat. Libraries, Book Stands, and portals use Legacy-inspired
assets and sounds. Handheld travel drops the used book at the departure point,
so plan how you will retrieve it.
Craft a Book Binder from three iron ingots and five planks. Put leather or an
empty Notebook in the left cover slot. Enter a title, then place a Link Panel
Page first in the horizontal page strip. Click a page in the strip with a
written Page on the cursor to insert it before that position; click it with an
empty cursor to take it out. The arrows or mouse wheel scroll through up to
27 written Pages. Clicking the strip with a filled Notebook imports its Pages
at that position and leaves the emptied Notebook on the cursor. The right slot
outputs a Descriptive Book once the title, cover, and Link Panel are present.
Inputs are consumed only when the result is taken.
## Requirements and optional integrations
The completed book receives its Age identity when assembled. With only a Link
Panel, it describes a randomly generated Age. Its writing is stored on the
book; the Descriptive Book screen's turned pages show ordered symbols and
unresolved IDs. Until Milestone 5, symbols do not affect generation and written
books use the same random Age fallback. The Descriptive Book screen follows
Legacy's book spread: the title and Age details are on the left, the destination
preview is on the right, and clicks in the bottom corners turn pages. Ordered
symbol pages appear before the final blank spread; their pictograms await
Milestone 5. Link-panel-only books show their details in the enchantment-table
font.
Install the same gameplay mods on the client and server:
## Linking Book
- Minecraft **1.21.1**, NeoForge **21.1.264 or newer in the 21.1 line**, and
Java 21.
- **Dynamic Dimensions AR Edition 0.9.1** and its **Bad Packets 0.8.2**
dependency. These are required for Age dimensions.
Craft an unwritten Linking Book from one Link Panel Page and one leather in any
two crafting-grid positions. An unbound book opens a screen where it can be named and bound to the
current location. A bound book opens its destination screen and travels when
its preview panel is clicked. The destination is stored on the item and
survives inventory and world saves. Travel only succeeds if the target
dimension is loaded and a non-fluid, passable landing position can be found
nearby.
The Linking Book uses the same Legacy page layout and bottom-corner page turns
as the Descriptive Book, but without the Descriptive Book's gold cover edges.
Both screens play Minecraft's book-page-turn sound when a page is turned.
Rivencraft works without any of these optional integrations:
Successful book travel plays Mystcraft Legacy's original linking sound at both
the departure and arrival locations.
| Mod | What it adds |
| --- | --- |
| JEI | Searchable Pages and guides for the Ink Mixer, Writing Desk, Book Binder, and Linking Book crafting. |
| Create (bundles Ponder) | Ponder book snapshots and a crystal-portal construction guide. |
| Immersive Portals | Live destination views in book previews and crystal portals. In `AUTO` preview mode it takes priority over Ponder. |
| TerraBlender | Eligible biomes from mods using its Overworld/Nether regions become Age biome candidates and Pages. Biomes O' Plenty is one example; it is not hard-coded or required. |
| Serene Seasons (with GlitchCore) | Independent per-Age seasons, including normal seasons, no seasons, and four eternal-season modes. |
| Ruins | Authorable Ruins-template Pages and Ruins structures in Ages. |
Right-click an empty vanilla lectern with a Rivencraft book to convert it into
a Book Stand and place the book on it. Use a Linking Book on the stand by
right-clicking with an empty hand. Sneak-right-click with an empty hand to take
the book back and restore the vanilla lectern. Breaking a Book Stand drops both
the stored book and a vanilla lectern.
The optional builds used in the latest development checks are JEI
19.69.0.457, Create 6.0.10, Immersive Portals 6.0.7, TerraBlender 4.1.0.8,
Serene Seasons 10.1.0.9, and Ruins 1.21.1.2NF. See the
[compatibility review](docs/MILESTONE_7_COMPATIBILITY_REVIEW.md) for the
evidence and limits of those combinations.
Bound Linking Books show a rotating Ponder snapshot when Create is installed.
Without Create, they use a built-in animated linking panel tinted by the
destination sky. The server independently validates every link.
Without Create or Immersive Portals, book previews use Rivencraft's built-in
Legacy-style animation. Ecliptic Seasons is **not supported**: its 1.21.1 API
does not offer the live dimension/season control Rivencraft needs.
## Descriptive Book
## Saves, settings, and current limits
Open a page-free Descriptive Book, name its Age, and press Create. Rivencraft
assigns the book a permanent Age UUID, selects a deterministic base terrain,
creates a dedicated runtime dimension, and prepares a five-by-five cobblestone
arrival platform at the
highest suitable nearby terrain. Solid blocks and tree leaves are valid
footing. Candidate columns are scanned from the top down so cave floors are not
selected, and fluid-covered columns are excluded. If no suitable surface is
available, the platform is generated at Y=63. Reopen the book and click its preview to travel.
The book cannot reopen while its Age is being generated, whether held or on a
Book Stand. Once creation finishes, reopening shows the written book.
Copies of the written book continue to resolve to the same persistent Age
across server restarts.
Ages and bound books use persistent UUIDs. Back up the **entire world folder**
before changing mod versions, data packs, or optional biome mods: the Age
registry and dimension data must be restored together. Rivencraft preserves
known older save formats where possible, but pre-public-release worlds are not
promised universal compatibility. Missing custom symbols retain their IDs in
books; missing registry entries or dimension providers can still prevent an
Age from loading. See [current implementation](docs/CURRENT_IMPLEMENTATION.md)
for supported behavior and deliberate limits.
New Age dimension folders use a Minecraft-safe lowercase form of the assigned
name. Conflicts receive incrementing suffixes such as `_2` and `_3`; existing
Age folders are never renamed.
Server settings include `descriptive_books.return_link_mode` (default
`DISABLED`), `libraries.loot_mode` (`LEGACY` or `AGE_ONLY`), writing ink costs,
season enrollment, and instability controls. The client setting
`book_preview.mode` chooses `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
An operator can inspect Ages and instability with `/rivencraft age` and find
libraries with `/rivencraft library nearest`. The creative-only Link Modifier
can edit link properties and retire an Age; use it carefully and keep backups.
Binder status messages are off by default. An OP can run
`/rivencraft binder messages enable` or `/rivencraft binder messages disable`
to control them; only OPs see the diagnostics even when enabled.
Page-free Ages deterministically select one of six base terrains inspired by
Mystcraft Legacy: Standard, Amplified, Island, Cave, Flat, or Void. The chosen
profile is saved with the Age and does not change when the server restarts.
## Project and development
Linking creates a bound Return Link to the exact source position and orientation
according to the configured Return Link policy.
This repository is the development source. See the [current implementation](docs/CURRENT_IMPLEMENTATION.md),
[milestone summaries](docs/MILESTONES.md), [technical document index](docs/README.md),
and [issues](https://git.paddedcell.org/dnorberg/rivencraft/issues).
Help build the [Rivencraft wiki](https://git.paddedcell.org/dnorberg/rivencraft/wiki)
with guides for Ages, pages, and crafting, or open an issue with documentation
suggestions and corrections.
Milestone 7 is complete in 1.0.0.2. Its [acceptance record](docs/MILESTONE_7_ACCEPTANCE.md)
and [regression matrix](docs/MILESTONE_7_REGRESSION.md) distinguish automated
checks from manual evidence and known limitations.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
Age creation, persistence, lifecycle commands, configuration, backups, and
administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
Data-pack runtime profiles are documented in
[docs/AGE_PROFILES.md](docs/AGE_PROFILES.md).
## Requirements
- A Java 21 JDK
- Minecraft 1.21.1
- NeoForge 21.1.251 or newer in the 21.1 line
- Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server)
Create is optional. When installed, Rivencraft uses its Ponder renderer for
book previews when Immersive Portals is absent. Immersive Portals is also
optional and takes priority in the default `AUTO` mode, providing a live
rotating destination view. Its Explore button enables WASD and jump/sneak
camera movement plus left-drag camera look; right-click returns to the rotating
view. Without either integration, Rivencraft uses its built-in animated linking
panel. Clients can set `book_preview.mode` in
`rivencraft-client.toml` to `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
Unavailable optional renderers safely fall back to `LEGACY`.
The complete backend matrix, supported combinations, cleanup behavior, and
known upstream limits are documented in
[docs/PREVIEW_COMPATIBILITY.md](docs/PREVIEW_COMPATIBILITY.md).
## Build and run
```shell
```sh
./gradlew build
./gradlew runClient
./gradlew runGameTestServer
```
The built mod is written to `build/libs/`.
The default dedicated-server GameTest run is deliberately small. Use
`./gradlew runGameTestServer -PwithArchivedGameTests=true` for the complete
regression suite before a release or when investigating a related issue. See
[docs/GAMETESTS.md](docs/GAMETESTS.md).
The JAR is written to `build/libs/`. Development runs need the required mods;
see [GameTest guidance](docs/GAMETESTS.md) for focused archived suites.
Versions use `x.y.z.b`: major release, completed milestone, release slice,
then fixes/additional work within that slice.
## Versioning
Rivencraft versions use `x.y.z.b`:
- `x` changes for a major public release.
- `y` changes when a development milestone is completed.
- `z` changes for each release slice within the current milestone.
- `b` changes for bug fixes and additional work within that release slice.
The build component resets to zero when a new release slice begins. Completing
a milestone increments `y` and resets both `z` and `b` to zero. For example,
milestone 2 development can progress from `0.1.1.0` to `0.1.1.1`, then
`0.1.2.0`; completing the milestone produces `0.2.0.0`.
## Porting approach
Mystcraft Legacy targets Forge for Minecraft 1.12.2. Rivencraft therefore
ports one subsystem at a time onto current registries, codecs, data packs, and
NeoForge APIs instead of importing the legacy source tree wholesale.
See [docs/PORTING.md](docs/PORTING.md) for the roadmap, live Gitea tracking
links, architectural direction, and provenance rules.
## License
Rivencraft is licensed under LGPL-3.0-or-later. Mystcraft Legacy is also
LGPL-3.0; copied or adapted upstream code must retain its copyright and license
notices. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
Rivencraft is licensed under LGPL-3.0-or-later. Legacy-derived material is
credited in [NOTICE](NOTICE) and [asset provenance](docs/ASSET_PROVENANCE.md).
+96 -32
View File
@@ -17,7 +17,7 @@ tasks.named('wrapper', Wrapper).configure {
version = mod_version
group = mod_group_id
List<String> archivedGameTestSuites = ['linking', 'writing', 'desk', 'binder', 'mixer', 'recipe']
List<String> archivedGameTestSuites = ['linking', 'environment', 'writing', 'grammar', 'generation', 'desk', 'binder', 'mixer', 'recipe', 'instability']
List<String> selectedArchivedGameTestSuites = providers.gradleProperty('withArchivedGameTests')
.map { it.toBoolean() }.orElse(false).get() ? archivedGameTestSuites
: providers.gradleProperty('archivedGameTestSuites').orElse('').get()
@@ -38,6 +38,35 @@ sourceSets.main.resources {
exclude("src/generated/**/.cache") // datagen cache files
}
// Generate the client-safe creative-page index from bundled symbol definitions.
// New built-in symbols then appear in the Pages tab without another Java list.
abstract class GenerateCreativeSymbolIndex extends DefaultTask {
@InputDirectory abstract DirectoryProperty getSymbolsDir()
@OutputDirectory abstract DirectoryProperty getIndexDir()
@TaskAction void generate() {
File root = symbolsDir.get().asFile
def definitions = new LinkedHashMap<String, Object>()
java.nio.file.Files.walk(root.toPath()).withCloseable { paths ->
paths.filter { java.nio.file.Files.isRegularFile(it) && it.toString().endsWith('.json') }
.sorted().forEach { source ->
String path = root.toPath().relativize(source).toString()
.replace('\\', '/').replaceAll(/\.json$/, '')
definitions["rivencraft:${path}"] = new groovy.json.JsonSlurper().parse(source.toFile())
}
}
File index = new File(indexDir.get().asFile, 'assets/rivencraft/creative_symbol_pages.json')
index.parentFile.mkdirs()
index.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(definitions)) + '\n'
}
}
def generateCreativeSymbolIndex = tasks.register('generateCreativeSymbolIndex', GenerateCreativeSymbolIndex) {
symbolsDir.set(layout.projectDirectory.dir('src/main/resources/data/rivencraft/rivencraft/symbols'))
indexDir.set(layout.buildDirectory.dir('generated/resources/creativeSymbols'))
}
sourceSets.main.resources.srcDir generateCreativeSymbolIndex
// Archived suites use separate namespaces but share the same empty structure.
tasks.named('processResources', ProcessResources).configure {
archivedGameTestSuites.each { suite ->
@@ -62,6 +91,11 @@ repositories {
content { includeGroup 'curse.maven' }
}
maven { url = 'https://maven.bai.lol' }
maven {
name = 'TerraBlenderMaven'
url = 'https://maven.minecraftforge.net/releases/'
content { includeGroup 'com.github.glitchfiend' }
}
maven {
url = 'https://api.modrinth.com/maven'
content { includeGroup 'maven.modrinth' }
@@ -98,6 +132,8 @@ neoForge {
server {
server()
// Keep dedicated compatibility runs away from the normal dev world.
gameDirectory = project.file(project.findProperty('serverRunDir') ?: 'run')
programArgument '--nogui'
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
@@ -148,42 +184,52 @@ neoForge {
}
}
// GameTests generate Ages, so their dedicated world can grow quickly across runs.
// Never auto-delete a caller-supplied gameTestRunDir or a normal dev world.
long gameTestWorldLimitMiB = providers.gradleProperty('gameTestMaxWorldMiB').orElse('512').get().toLong()
if (gameTestWorldLimitMiB <= 0) {
throw new GradleException('gameTestMaxWorldMiB must be a positive number')
}
// Vanilla's GameTestServer uses a fixed world seed of 0. Every default run
// regenerates its world; custom run directories are never managed here.
boolean customGameTestRunDir = providers.gradleProperty('gameTestRunDir').isPresent()
File dedicatedGameTestDir = layout.buildDirectory.dir('gametest-run').get().asFile.canonicalFile
File dedicatedGameTestWorld = new File(dedicatedGameTestDir, 'world')
def cleanOversizedGameTestWorld = tasks.register('cleanOversizedGameTestWorld') {
File dedicatedGameTestBuildDir = layout.buildDirectory.get().asFile
File dedicatedGameTestDir = layout.buildDirectory.dir('gametest-run').get().asFile
def prepareGameTestWorld = tasks.register('prepareGameTestWorld') {
onlyIf { !customGameTestRunDir }
doLast {
if (customGameTestRunDir) {
getLogger().lifecycle('Skipping automatic GameTest world cleanup for custom gameTestRunDir')
return
}
def worldDir = dedicatedGameTestWorld
if (!worldDir.isDirectory() || java.nio.file.Files.isSymbolicLink(worldDir.toPath())) {
return
}
def paths = java.nio.file.Files.walk(worldDir.toPath()).withCloseable { it.toList() }
long bytes = paths.sum(0L) { path ->
java.nio.file.Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)
? java.nio.file.Files.size(path) : 0L
}
long limit = gameTestWorldLimitMiB * 1024L * 1024L
if (bytes > limit) {
getLogger().lifecycle('Removing oversized GameTest world {} ({} MiB; limit {} MiB)',
worldDir, Math.ceil(bytes / 1048576d) as long, gameTestWorldLimitMiB)
paths.toSorted(java.util.Comparator.reverseOrder()).each { java.nio.file.Files.delete(it) }
}
GameTestWorlds.clean(dedicatedGameTestBuildDir, dedicatedGameTestDir)
}
}
def finishGameTestWorld = tasks.register('finishGameTestWorld') {
onlyIf { !customGameTestRunDir }
doLast {
GameTestWorlds.clean(dedicatedGameTestBuildDir, dedicatedGameTestDir)
}
}
tasks.named('runGameTestServer').configure {
dependsOn(cleanOversizedGameTestWorld)
dependsOn(prepareGameTestWorld)
finalizedBy(finishGameTestWorld)
}
class GameTestWorlds {
static void check(File buildDir, File runDir) {
[buildDir, runDir, new File(runDir, 'world'), new File(runDir, 'failed-worlds'),
new File(runDir, 'failed-seed.txt'), new File(runDir, '.last-gametest-succeeded')].each { path ->
if (java.nio.file.Files.isSymbolicLink(path.toPath())) {
throw new GradleException("Refusing to manage a symlinked GameTest path: ${path}")
}
}
}
static void clean(File buildDir, File runDir) {
check(buildDir, runDir)
[new File(runDir, 'world'), new File(runDir, 'failed-worlds')].each { root ->
if (root.exists()) {
java.nio.file.Files.walk(root.toPath()).withCloseable { paths ->
paths.sorted(java.util.Comparator.reverseOrder()).forEach { path ->
java.nio.file.Files.delete(path)
}
}
}
}
java.nio.file.Files.deleteIfExists(new File(runDir, 'failed-seed.txt').toPath())
java.nio.file.Files.deleteIfExists(new File(runDir, '.last-gametest-succeeded').toPath())
}
}
// Sets up a dependency configuration called 'localRuntime'.
@@ -199,6 +245,24 @@ dependencies {
// Keep them on the compile classpath without forcing either mod at runtime.
compileOnly "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}"
compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}"
// Ruins is optional; its pre-spawn event is only registered when Ruins is loaded.
compileOnly 'curse.maven:ruins-227873:6391825'
if (providers.gradleProperty('withRuins').map { it.toBoolean() }.orElse(false).get()) {
localRuntime 'curse.maven:ruins-227873:6391825'
}
// TerraBlender's region API is only used by the guarded compat adapter.
compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
if (providers.gradleProperty('withTerraBlender').map { it.toBoolean() }.orElse(false).get()) {
localRuntime "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
}
// The optional compatibility adapter uses Serene's dimension config API.
// Neither mod is a required dependency of the published Rivencraft jar.
compileOnly "maven.modrinth:e0bNACJD:${serene_seasons_version_id}"
compileOnly "maven.modrinth:s3dmwKy5:${glitchcore_version_id}"
if (providers.gradleProperty('withSereneSeasons').map { it.toBoolean() }.orElse(false).get()) {
localRuntime "maven.modrinth:e0bNACJD:${serene_seasons_version_id}"
localRuntime "maven.modrinth:s3dmwKy5:${glitchcore_version_id}"
}
implementation "curse.maven:dynamicdimension-ar-edition-1469680:${dynamic_dimensions_file_id}"
implementation "lol.bai:badpackets:neo-${badpackets_version}"
// JEI integration is isolated in its plugin and absent from the published runtime dependencies.
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

+13 -13
View File
@@ -11,7 +11,7 @@ suffixes such as `_2` and `_3`.
The selected terrain profile, biome distribution, lighting, weather, celestial
period, celestial angle, and phase are deterministic and become part of the
saved definition. A five-by-five cobblestone platform with clear headroom is
saved definition. A five-by-five non-dropping Crystal Platform with clear headroom is
prepared at a safe arrival point. The completed book and every copy carrying
the same UUID resolve to that definition.
@@ -42,10 +42,11 @@ on demand when an administrator previously unloaded it. Unloading retains the
definition and world folder, so existing books continue to reference the same
Age and later reload the same terrain.
Rivencraft does not automatically delete Ages. Safe permanent deletion needs a
complete reference index for books in inventories, containers, Book Stands,
and unloaded chunks. Until that exists, retaining the definition prevents an
administrator action from silently orphaning books.
Rivencraft does not automatically delete Ages. The operator-only creative Link
Modifier can deliberately retire an unoccupied Age: it unloads the dimension,
moves its data to a sibling `<world>-retired-ages/<uuid>` archive, and records
the UUID as dead so existing books cannot silently regenerate it. Back up the
whole world first.
Destination-preview chunk loaders are temporary. Rivencraft releases them when
the book closes, the player travels, the player disconnects, the destination
@@ -69,10 +70,9 @@ definition.
Rivencraft writes gameplay settings to the world/server configuration managed
by NeoForge. `descriptive_books.return_link_mode` accepts:
- `ON_CREATION` (default): create one Return Link the first time a player
enters each Age.
- `DISABLED` (default): never create Return Links automatically.
- `ON_CREATION`: create one Return Link the first time a player enters each Age.
- `ALWAYS`: create a Return Link after every successful Descriptive Book trip.
- `DISABLED`: never create Return Links automatically.
Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode`
setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes
@@ -102,8 +102,8 @@ Use this sequence when a book cannot load an existing Age:
matching dimension data and authoritative world saved data from the same
backup generation. Do not combine them from different backups.
There is intentionally no delete command in Milestone 3. Do not manually
delete an Age definition or rename its dimension folder: books may exist in
inventories, containers, Book Stands, or unloaded chunks, and would be
orphaned. `/rivencraft age unload` is the supported reversible maintenance
operation.
Do not manually delete an Age definition or rename its dimension folder:
books may exist in inventories, containers, Book Stands, or unloaded chunks.
`/rivencraft age unload` remains the reversible maintenance operation. The
Link Modifier's retirement control is not reversible through a book UI;
recover its archived data and registry together from a full-world backup.
+7
View File
@@ -0,0 +1,7 @@
# Age arrival platform
Newly created Ages place a 5×5 solid platform instead of cobblestone at their saved spawn. The block has full collision, renders as an opaque full block, breaks as easily as glass (0.3 hardness), has no loot table, and cannot be obtained with pick-block. Existing Ages retain their already-placed platform blocks, which adopt the new appearance and hardness without altering saved terrain.
The model reuses `assets/rivencraft/textures/block/crystal_block.png`, the 16×16 Legacy crystal tile. The former generated cloud texture is removed. The internal block ID remains `rivencraft:cloud_platform` to preserve saved worlds.
The separate [decay provider](INSTABILITY_DECAY.md) protects the saved spawn area by position, so its protection remains in force regardless of the platform's material.
+263 -5
View File
@@ -30,15 +30,273 @@ resources and can be overridden by a higher-priority pack. The example under
directory. Valid custom profiles join the stable, sorted pool used when a new
Age rolls its base terrain; the selected ID is then persisted with that Age.
Each built-in profile now has a matching `terrain/<name>` Symbol Page. Put one
of those pages after the Link Panel in a Book Binder to select that terrain
instead of the seed-selected random profile. Standard, Amplified, and Cave use
their corresponding registered noise settings; Island uses Minecraft's
floating-islands noise settings; Flat and Void use flat-source generators.
The Cave and Island pages are Rivencraft terrain choices based on existing
profiles, not claims that Legacy had identically named terrain symbols.
Every family retains a safe cobblestone arrival platform. An omitted terrain
page still uses the deterministic seed-selected profile.
### Cave terrain and Legacy
Legacy's closest terrain generator is its Nether terrain symbol: it owns an
Age-seeded octave-noise generator, accepts terrain/sea materials, and sets a
sea level of 32. Legacy's separate Caves symbol instead registers a terrain
alteration that carves additional caves; it is not a terrain generator.
Minecraft 1.21.1 no longer has Legacy's 1.12.2 height and generation pipeline,
so Rivencraft Cave terrain uses `minecraft:caves` noise and the modern Cave,
Nether, and tagged modded Nether biome decorations. It cannot reproduce the
exact Legacy noise or its older vertical layout. The Caves feature page
remains a separate additive carver for other suitable terrain.
Cave Ages search for safe spawn footing from Y=64 downward rather than using
the cave roof; if necessary, the usual cloud platform is placed at Y=63.
Weather is clear and Serene Seasons is disabled in Cave terrain. Authored
Weather, Season, and Celestial pages (including time modifiers) are retained
in the book but have no effect on the Cave Age; each adds 100 base instability.
The same policy applies to randomly selected Cave terrain and persists through
save/reload. Cave biome selections remain limited to Cave and Nether biomes;
surface-biome completion is not used for Cave terrain.
The separate **Floating Islands** feature page adds rare high-altitude island
clusters above Standard, Amplified, Sky Islands, Flat, or Void terrain; it does
not replace the terrain page. The feature is seeded by the Age and saved with the written
plan, so revisiting an Age recreates the same islands. Without a preceding
Block page, the Age seed chooses one valid full-collision block from the Block
Pages pool for the island body. A full-block material page immediately before
Floating Islands overrides that choice. Ordinary surface biomes still get
dirt/grass top layers; Nether and End biomes use the chosen block throughout.
The feature is additive even on Sky Islands terrain,
which already forms its own islands. Cave terrain rejects it. A page-free random
Age may also roll Floating Islands as one of its material features.
An unpaired Block or Fluid page chooses a compatible feature from the Age seed:
full blocks can become Rocks, Floating Islands, Tendrils, Spheres, or Spikes; source fluids can become one
of the lake features. Flat terrain can use Floating Islands, Tendrils, Spheres,
or Spikes; Void can use Floating Islands, Tendrils, or Spheres; Cave can use
Spheres or Tendrils. A page-free
random Age rolls zero, one, or two distinct compatible material features, choosing each
material from the Block or Fluid Pages pool. The choices are saved on the Age
definition, so opening the Age again does not reroll them.
Repeated Block/Fluid and Feature pages remain separate entries in authored
writing. Each unpaired material gets its own compatible feature, and repeated
decorations use separate placement rolls. Repeated unmodified feature pages
choose different compatible materials until their material pool is exhausted.
Structure pages are separate Feature symbols. Legacy's Dungeons, Mineshafts,
Nether Fortresses, Strongholds, and Villages have writable pages, as do modern
Trial Chambers, Bastion Remnants, Buried Treasure, Shipwrecks, Ocean Ruins,
Pillager Outposts, Ruined Portals, Nether Fossils, Ancient Cities, and Trail
Ruins. They use Minecraft's 1.21.1 structure templates and placement rules,
not a recreation of 1.12.2 structure geometry. A page enables only its own
structure set; absent pages do not inherit these structures from a biome.
Nether Fortresses and Bastions use separate sets instead of vanilla's shared
Nether-complexes set, so writing one does not enable the other. Dungeons use
the vanilla monster-room feature with eight attempts per chunk; unpaged Ages
do not inherit biome-default monster-room attempts. A page-free random Age
may choose zero to two distinct compatible structure types, frozen on creation.
An authored structure needs a compatible biome in the Age's selected pool.
Fortresses, Bastions, and Nether Fossils require Cave terrain plus a Nether
biome; surface-only structures reject Cave terrain. Void rejects all of these
structure pages. Villages and Strongholds can also be written into Flat Ages.
For visual testing, write a Standard/Plains/Single Age with Mineshafts,
Villages, and Trial Chambers, then use `/locate structure` in that Age to find
each. An unwritten Pillager Outpost should not locate there. Test a Cave Age
with Nether Wastes/Single and either the Nether Fortresses or Bastion page:
only the selected type should locate. Ocean structures need their matching
ocean or beach biome page; Ancient Cities need Deep Dark. Check the new pages
in Rivencraft's Feature creative tab and JEI as well.
**Dense Ores** is an authored-only Feature page. Each copy replays one extra
pass of every ore placement registered in the Age's nearby biomes, using that
placement's existing height, vein-size, rarity, and biome rules. It therefore
also densifies conventional mod-added ores without hard-coded mod IDs. Output
blocks tagged `c:ores` qualify; OreConfiguration outputs with `_ore` in their
registry name also qualify. A mod with a custom, non-OreConfiguration placed
feature can opt in by tagging that feature `rivencraft:dense_ores`. Non-ore
stone/dirt blobs are not repeated. Flat and Void reject the page, as does a
biome pool without any ore placements. Page-free random Ages never roll Dense
Ores. The extra pass applies only as new chunks generate; it does not retrofit
existing chunks or discover ore systems that bypass biome placed features.
For an in-game check, write a Standard/Plains/Single Age with Dense Ores and
verify that new underground chunks contain visibly more vanilla ore while
stone/dirt blob frequency remains unchanged. With an ore-adding mod installed,
write an Age using a biome where that mod registers its ore and verify the ore
also appears more often. Check that Flat plus Dense Ores is rejected by the
Binder and that a random, Link-Panel-only Age has no Dense Ores feature page.
**Obelisks** adds Legacy-shaped, rare 4×4 plinths with a 2×2 shaft rising 12
blocks, at most one attempt per 128 chunks per page. A preceding full-block
Block page chooses the material; otherwise the Age seed chooses from valid
block pages. Repeated Obelisks pages make independent attempts. Generation is
deterministic and stays within one chunk; it requires a reasonably level,
exposed solid surface, so Cave and Void terrain reject the page. Standard,
Amplified, Sky Islands, and Flat terrain permit it. Page-free random surface
Ages can roll Obelisks as one of their material features.
For a visual check, write a Standard or Flat Age with an Obelisks page and
search newly generated terrain for the 4×4 base and 12-block shaft. Try a
Block page such as Obsidian immediately before Obelisks to verify its material
changes. The Binder should reject Obelisks in Cave and Void Ages. Placement
is deliberately rare, so several hundred fresh chunks may be needed.
For an in-game visual check, bind Descriptive Books with a Link Panel,
one of Standard, Amplified, Sky Islands, Flat, or Void terrain, and the Floating Islands
feature page in that order. Fly through each Age at roughly Y=170–220 and
verify the elevated clusters appear above its unchanged base terrain. Reopen
the same Age to check that island positions stay fixed. Islands should be less
common than in the first test build (one source chunk in 128 instead of 64).
Sky Islands should retain its terrain islands and gain occasional additional
feature islands. Try Cave terrain too; the Binder should reject that pairing.
**Skylands** is a separate subtractive feature, not the Sky Islands terrain
profile or the additive Floating Islands feature. Following Legacy's primer
filter, it removes base terrain below a smooth, seven-octave cutoff around
world Y=76 and removes liquid above the cutoff. The selected terrain still
generates first; Floating Islands and Tendrils are added afterward, so they
can coexist with Skylands. Skylands has no Block-page modifier. It can be
authored on every terrain, though Flat loses its low ground and Void has no
base terrain to change. Page-free random noise Ages have a seeded one-in-eight
chance to gain Skylands; random Flat and Void Ages do not roll this ineffective
filter. The cloud-height and horizon changes from Legacy are not included yet.
For an in-game check, compare Standard terrain with and without a Skylands
page. Skylands should leave upper hills suspended over open space, while a
Floating Islands page instead adds isolated high islands without removing the
ground below. Try Sky Islands terrain plus Skylands to confirm the page filters
the terrain rather than replacing its generator. A book with both Skylands and
Floating Islands should retain the added feature islands above the cutoff.
The **Tendrils** feature adapts Legacy's winding, branching cave-node geometry
into solid tubes. It is additive on all current terrain families, including
Flat, Void, and Cave; it does not change biome selection. A preceding full-
collision Block page chooses the tendril material. Otherwise the Age seed
chooses a valid Block Pages material, consistent with other authored material
features. Page-free random Ages can select Tendrils as a solid-material feature,
including on Cave terrain where the surface material features are unavailable.
The shape and placement use only the Age seed and source-chunk coordinates, so
revisiting an Age recreates the same geometry.
**Spheres** and **Spikes** are two separate Feature pages. Spheres adds
winding chains of solid sphere nodes within terrain generation; like Tendrils,
it works on all terrain families, even Void and Cave. Spikes is a later,
rarer surface decoration (roughly one attempt per 18 chunks) that rises from
solid ground on Standard, Amplified, Sky Islands, or Flat terrain. Cave and
Void reject the Spikes page because they do not provide a suitable exposed
surface. Each page accepts its own immediately preceding full-collision Block
page; without one, the Age seed chooses a compatible material. The pages
remain distinct in the catalog, Creative menu, JEI, ordered writing, and
saved Age definition. Page-free random Ages can select either where compatible.
For an in-game check, make separate Void/Spheres and Flat/Spikes books with
Glowstone Block pages directly before their respective Feature pages. Spheres
should form connected bright globes across chunk boundaries in Void; Spikes
should appear as rare upward clusters rooted on the Flat surface. Revisit the
same Ages to confirm stable locations. Check that a Void/Spikes book is
rejected, and that Spheres and Spikes remain two independently selectable
pages in Creative and JEI.
For a clear visual check, bind a Void Age with a Glowstone Block page followed
by Tendrils. Fly around world Y=0–120 across several chunks: branching solid
forms should appear in the otherwise empty dimension, continue across chunk
borders, and remain in place after leaving and returning. Repeat with a lone
Tendrils page to confirm its seed-chosen material, and with Cave terrain to
confirm the feature does not require a surface biome.
## Written biome selection (generation version 16)
Biome pages follow the terrain page and precede the distribution-control page
in the Binder. Bundled controls are Native, Single, Tiled, Grid, and Tiny through
Huge regions. Single needs one distinct biome; Tiled/Grid and Tiny–Huge need
two. An authored book receives seeded filler only when it falls below that
minimum, while extra authored pages are retained as weighted entries. A book
with no biome pages selects a seeded count from two up to the number of
eligible biomes for Tiled/Grid and Tiny–Huge. The eligible pool comes from the
terrain's biome preset, or `#rivencraft:cave_biomes` for Cave terrain, so future
biome integrations can expand it without changing the count rule. Single uses
one; Native uses its preset biome pool. This random-age maximum does not truncate
authored pages: the Binder's 252 symbol-page positions bound an authored book. Legacy's Binder itself had no explicit page-count check; this remains a fixed-slot storage ceiling.
Native cannot be paired with explicit biome pages. Legacy filled Tiny–Huge to
three; Rivencraft deliberately uses two so a pair of written biome pages stays
exactly as written.
The Biome Pages creative tab offers pages for every registered vanilla biome.
Hand-authored symbol definitions take precedence; the remaining vanilla pages
are generated from the biome registry, so they do not each need a JSON file.
Custom/modded biomes still require a data-pack symbol definition.
Standard, Amplified, and Sky Island terrain fill from the vanilla Overworld
biome preset, including its underground Lush Caves, Dripstone Caves, and Deep
Dark biomes. Standard terrain therefore keeps Overworld-style cave generation,
and cave biome pages can also be written explicitly. Sky islands can use an
explicitly authored End biome page.
Cave terrain fills only from `#rivencraft:cave_biomes` (Lush Caves, Dripstone
Caves, and Deep Dark by default); explicitly authored Nether biome pages are
also accepted. Other cave/biome pairings are diagnosed and blocked for now.
Milestone 6 instability should allow such illegal combinations as unstable
Ages, without silently treating them as ordinary cave generation. Flat terrain
uses Overworld choices when a distribution is authored; its Native default
remains Plains. Void terrain defaults to The Void when no biome page is written,
but accepts authored biome pages. One authored biome uses Single distribution
when no controller is written. An explicit multi-biome distribution needs enough
authored biomes; The Void is not silently added to fill missing positions. These
biome regions exist even without blocks or a Floating Islands page, so biome-
dependent weather and optional season behavior can still vary there. To check
this in-game, bind Void terrain with a Desert Biome page and no feature page:
the Age should be empty while F3 reports Desert. A Void book without biome
pages should instead report The Void.
The resolved list of biome IDs, their order/repetitions, and distribution are
frozen into the Age plan. A removed biome ID emits a warning and uses an
eligible fallback for that load; restoring the biome recovers the original
selection. Generation-version-15 Ages keep their prior registry-wide biome
selection algorithm instead of being silently regenerated.
## Written environment
Lighting pages select Normal, Dark, or Bright lighting for the new Age. A Sky
page selects Minecraft's Overworld, Nether, or End sky effects; the chosen
effects also determine the destination preview backdrop. Overworld sky tint
continues to come from the destination biome. A written sky changes only that
Age's dimension type, not the source world or other Ages.
Normal, Fast, Slow, Off, Always, and Storm weather pages use the existing Age
weather controller. Zero, Half, Full, and Double Length pages set the day-cycle
period, and Nadir, Rising, Zenith, and Setting pages set its initial phase.
Repeated numeric modifiers combine in written order. A Zero Length Age keeps
the nonzero minimum clock rate that avoids client skybox bounce. Pages for
Cloudy, forced Rain, and Snow are not offered: vanilla precipitation rendering
cannot reproduce those Legacy distinctions yet, and Snow remains planned for
the optional season integrations. A data pack that supplies these unsupported
weather choices receives a binding error rather than a false success.
The category creative tabs and optional JEI page ingredients use the same generated
index of bundled environment symbols. Existing Age definitions and page-free
random Ages retain their saved or seeded environment unchanged.
## Persistence and reload behavior
An Age permanently stores its selected profile ID, generation version, seed,
and rolled traits. `/reload` replaces the profile definitions but does not
reroll those immutable values. An unloaded Age resolves its stored profile
again when it loads, so intentional profile overrides affect its runtime
resources and newly generated chunks.
and rolled traits. For an **authored** Age, the generation plan also stores a
copy of the resolved runtime profile: terrain shape, noise settings, biome
preset, and dimension type. `/reload` cannot reinterpret that plan; an unloaded
authored Age uses the saved profile snapshot when it loads, even if the defining
symbol or profile JSON has since changed. An older, page-free Age has no plan
and continues to resolve its saved profile ID at load time.
A profile removed after an Age was created resolves to `rivencraft:standard`.
Registered noise, biome-preset, and dimension-type IDs are validated when
profiles load. Before loading an authored Age, the saved references are checked
again. If a data pack supplying one has been removed, the dimension stays
unloaded and the log identifies the missing resource; the Age's plan is not
silently replaced. Restore the pack to load that Age. A custom dimension type
retains its own skylight capability: normal lighting cannot make a Nether-style
type skylit.
A profile removed after a page-free Age was created resolves to `rivencraft:standard`.
The legacy `rivencraft:random_vanilla` alias also resolves to `standard`.
Rivencraft logs this fallback. Invalid built-in overrides do not replace the
required built-in definitions; a reload fails if a required built-in profile
+14 -4
View File
@@ -7,9 +7,8 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| Rivencraft asset | Upstream asset | Upstream revision | Changes |
| --- | --- | --- | --- |
| `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/gui/notebook_pages.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Notebook draws the native 30×40 page at atlas coordinates `(156, 0)` |
| `assets/rivencraft/textures/gui/notebook_pages.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; shared by the Notebook, Desk, Binder, and turned left book page after removing a byte-identical duplicate |
| `assets/rivencraft/textures/gui/bookui_cover.png` | `assets/mystcraft/gui/bookui_cover.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; shared book cover, with gold edges drawn only for Descriptive Books |
| `assets/rivencraft/textures/gui/bookui_pagel.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; turned left page |
| `assets/rivencraft/textures/gui/bookui_pager.png` | `assets/mystcraft/gui/bookui_pager.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; link-panel frame on the first right page |
| `assets/rivencraft/textures/gui/bookui_rpage_full.png` | `assets/mystcraft/gui/bookui_rpage_full.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; full right written page |
| `assets/rivencraft/textures/item/writing_desk.png` | `assets/mystcraft/textures/items/writingdesk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
@@ -20,10 +19,21 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture |
| `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel |
| `assets/rivencraft/textures/gui/ink_mixer_legacy.png` | `assets/mystcraft/gui/inkmixer.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Ink Mixer GUI uses the original 176×181 panel |
| `assets/rivencraft/textures/block/ink_mixer_top.png`, `ink_mixer_side.png`, `ink_mixer_bottom.png` | `assets/mystcraft/textures/blocks/inkmixer_top.png`, `inkmixer_side.png`, `inkmixer_bottom.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Rivencraft supplies its own block model |
| `assets/rivencraft/textures/block/inkwell_base.png`, `inkwell_cup.png`, `ink.png`, `ink_glass.png` | `assets/mystcraft/textures/entity/inkwell/` counterparts | Legacy 0.13.7.06 local extraction | Byte-identical copies used by the ported Ink Mixer geometry; the paper overlay uses vanilla white concrete instead of Legacy's black surface |
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
| `assets/rivencraft/textures/item/page_symbol*.png` and `page_unresolved.png` | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Static 1.21.1 renditions of the legacy runtime-composed four-word symbol pages; `normal_time` uses the legacy Full Length poem |
| `assets/rivencraft/textures/gui/symbolcomponents.png` | `assets/mystcraft/textures/symbolcomponents.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Byte-identical shared 512×512 component sheet for client-side page composition |
| Runtime-composed symbol pages (`SymbolPageTextures`) | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Replaces the six pre-generated symbol PNGs; layers four poem words on the shared page background and caches bounded client textures |
| `assets/rivencraft/textures/block/crystal_block.png` | `assets/mystcraft/textures/blocks/crystal.png` | Legacy 0.13.7.06 local extraction | Exact 16×16 blue tile shared by Crystal Block, Budding Crystal, and the non-dropping Age arrival platform; its former cloud sprite is removed |
| `assets/rivencraft/textures/block/{small,medium,large}_crystal_bud.png`, `crystal_cluster.png` | Minecraft 1.21.1 `textures/block/{small,medium,large}_amethyst_bud.png`, `amethyst_cluster.png` | Minecraft 1.21.1 | Original 16×16 alpha masks and silhouettes preserved; hue shifted toward Legacy blue, with brighter saturation; exported as 8-bit RGBA (not indexed PNG) |
| `assets/rivencraft/textures/item/crystal_shard.png` | Original sprite generated with OpenAI ImageGen from the Legacy crystal palette | 2026-10-05 | Cropped and nearest-neighbor scaled to 16×16 |
| `assets/rivencraft/sounds/linking/link-fissure.ogg` | `assets/mystcraft/sounds/linking/link-fissure.ogg` | Legacy 0.13.7.06 local extraction | Namespace/path rename only; plays when a Star Fissure returns an entity home |
| `assets/rivencraft/sounds/entity/meteor/roar.ogg` | `assets/mystcraft/sounds/entity/meteor/roar.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical copy under the Rivencraft namespace; plays during the bounded meteor descent |
| `assets/rivencraft/textures/block/book_receptacle.png` | `assets/mystcraft/textures/blocks/book_receptacle.png` | Legacy 0.13.7.06 local extraction | Byte-identical 16×16 face tile; modern block model recreates the Legacy thin slab |
| `assets/rivencraft/textures/block/crystal_portal.png` and `.mcmeta` | `assets/mystcraft/textures/blocks/portal.png` and `.mcmeta` | Legacy 0.13.7.06 local extraction | Byte-identical texture and line-ending-normalized animation metadata, used when Immersive Portals is absent |
| `assets/rivencraft/sounds/linking/link-portal.ogg` | `assets/mystcraft/sounds/linking/link-portal.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical portal-link sound |
| `assets/rivencraft/sounds/linking/link-disarm.ogg` | `assets/mystcraft/sounds/linking/link-disarm.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical disarm-link sound; takes precedence over the portal sound as in Legacy |
| `assets/rivencraft/sounds/linking/link-intra.ogg` | `assets/mystcraft/sounds/linking/link-intra.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical intra-link sound for Linking Books |
Mystcraft Legacy identifies XCompWiz as its project developer and includes the
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
+98
View File
@@ -0,0 +1,98 @@
# Current implementation (Milestone 7 development)
This is the maintained technical overview of Rivencraft as of the 1.0.0.2
slice. It describes implemented behavior, not every behavior in Mystcraft
Legacy. [The README](../README.md) is the player introduction;
[milestone summaries](MILESTONES.md) explain how the implementation arrived
here. The linked deep dives are retained as historical audits and should be
read with their version dates in mind.
## Books, linking, and previews
- Linking Books bind a named position and direction. Same-dimension use needs
Intra Linking. Descriptive Books refer to a persistent Age UUID and keep
their authored page order. Copies keep that identity.
- Book screens use Legacy-inspired page spreads and destination panels. `AUTO`
selects optional Immersive Portals first, then Create/Ponder, then the built-in
animation. Travel is validated on the server regardless of renderer.
- Handheld travel drops the active book at its departure point as a visible
book entity. Book Stands and crystal portals keep the book in place. Disarm
drops carried items; Maintain Momentum rotates and carries velocity.
- A vanilla Lectern converts to a Book Stand when given a Rivencraft book or
page. A Book Receptacle in a connected Crystal Block frame activates a
vertical or horizontal portal. Immersive Portals can display a live view;
otherwise the Legacy-style animated surface remains. Portal arrivals use
existing safe footing; a Descriptive portal does not generate a platform.
- Automatic Return Links default to `DISABLED`, with `ON_CREATION` and `ALWAYS`
server options. Linking Books do not generate platforms by default; a
creative Link Modifier can explicitly enable that property.
See [preview modes](PREVIEW_COMPATIBILITY.md), [Legacy M7 audit](LEGACY_M7_AUDIT.md),
and [Age administration](AGES.md).
## Ages, writing, and structures
- Dynamic Dimensions AR Edition creates Age worlds. Each Age has a saved UUID,
safe dimension ID, seed, spawn, traits, versioned generation plan, and
optional season mode. Page-free or Link-Panel-only books get seeded random
choices. Authored supported pages override the appropriate choices.
- Terrain choices are Standard, Amplified, Island, Cave, Flat, and Void. Cave
terrain uses cave/Nether biomes and an appropriate spawn search; normal
terrain starts from exposed surfaces. If no safe arrival site exists, the
Age-creation path can construct a non-dropping Crystal Platform at Y=63.
- The server catalog accepts typed data-pack symbols. The Binder preserves
written order and diagnoses conflicts. Its status readout is off by default;
`/rivencraft binder messages enable` makes it visible only to OPs. Symbol
Pages are composed at runtime from Legacy glyph assets. Number pages 0–24
set color strength or, immediately before a Length, Angle, Phase, or Size
modifier, its numeric value.
Celestial pages can create multiple suns/moons with independent color, size,
angle, period, phase, and direction; authored eclipses affect light.
- The Writing Desk stores tabs and copies known Symbol Pages with paper and
ink. Notebooks hold ordered pages; the Binder consumes a Link Panel and cover
to make a finished Descriptive Book. The Ink Mixer makes Link Panels and
rolls Disarm, Intra Linking, and Maintain Momentum from additives. Blank
Pages are internal bases, not a craftable or JEI-listed output.
- Features include terrain additions, decorations, configurable block/fluid
materials, ores, vanilla structures including newer 1.21 structures,
Legacy-style libraries, crystals, obelisks, and star fissures. Optional Ruins
templates gain authorable pages. Some biome/terrain combinations or feature
placements are deliberately unsupported and are diagnosed during binding.
See [symbol semantics](SYMBOL_SEMANTICS.md), [writing data](WRITING_DATA.md),
[generation profiles](AGE_PROFILES.md), and [library/Ruins rules](RUINS_COMPATIBILITY.md).
## Instability and optional mods
Invalid or excessive writing and valuable generated blocks can add
instability. Block abundance is profiled in loaded chunks, so its contribution
and selected effects can change as the Age is explored; the authored plan is
not rewritten. Effects are bounded to loaded Ages and include status effects,
colored decay, lightning, explosions, meteors, burning, and erosion. Operator
commands expose the score and providers; the book GUI does not. See
[scoring](INSTABILITY_SCORE.md) and [runtime policy](INSTABILITY_RUNTIME.md).
JEI, Create/Ponder, Immersive Portals, TerraBlender, Serene Seasons with
GlitchCore, and Ruins are optional. TerraBlender can supply biomes from any mod
using its eligible regions; Biomes O' Plenty is a test example. Serene Seasons
has per-Age normal/no/eternal modes and live Age enrollment. Ecliptic Seasons
is not integrated because its 1.21.1 API lacks supported live registration and
per-Age calendar control. See the [optional-mod matrix](MILESTONE_7_REGRESSION.md).
## Persistence and boundaries
An Age registry definition and its dimension folder belong to the **same
whole-world backup**. Known older book/writing, plan, workstation, and Age
formats have migration or stable-default handling. Unknown symbol IDs remain
in books; missing registry objects or removed dimension mods can still prevent
world loading. The OP-only Link Modifier can retire an unoccupied Age. That
operation marks its UUID dead and moves its dimension data to a sibling
`<world>-retired-ages/<uuid>` folder; it is not ordinary book maintenance.
Do not use it without a backup.
This is not an exact port of every Legacy generator, page, hazard, or client
renderer. Modern safety budgets and NeoForge's dimension/biome APIs require
adaptations. The [Milestone 7 regression matrix](MILESTONE_7_REGRESSION.md)
separates tested behavior from unverified multiplayer/restart/optional-mod
combinations. The [Milestone 7 acceptance record](MILESTONE_7_ACCEPTANCE.md)
records release evidence and remaining validation limits.
+16
View File
@@ -0,0 +1,16 @@
# Descriptive Book presentation (Milestone 5, issue #40)
The Legacy-style front spread has the Age name and author on the left and the destination panel on the right. The reference screenshots are `dev images/Pasted image.png` (front spread) and `dev images/Pasted image (2).png` (single-symbol spread). An authored book shows one full-size composed symbol on each turned right page, in the order stored in `BOOK_WRITING`; the former plain-text name and symbol ID are not shown for resolved pages. The last authored symbol is the last normal page, not a blank trailing page.
The Binder records the taking player's name on the finished book; direct Age binding also records the binding player. Older books without this component display `Unknown author` rather than attributing them to whoever happens to open them. Link-Panel-only and older page-free books retain their random-Age glyph details and destination preview. On authored books, hovering the front-page name exposes the existing trait summary without filling the left page with text.
The server resolves symbol display metadata from its current catalog. A missing data-pack symbol draws the unresolved page artwork and its original ID. Descriptive Books do not show writing notes or grammar warning markers; grammar validation remains in the Binder and on the server when an Age is created. Opening a book never changes its persisted Age plan. Symbol pages use the same bounded, runtime glyph composition as Page items and do not require Create, Ponder, or Immersive Portals.
## Manual visual and behavior checks
1. Bind a Descriptive Book with two distinct symbol pages. Open it: the front spread shows its Age name, your player name, and the link panel. Turn twice and confirm exactly one large symbol on each right page, in authored order, with no plain-text symbol description or blank trailing page.
2. Hover the front-page name to confirm the trait summary remains accessible. Click the link panel to confirm normal travel; repeat from a lectern to check the name, panel, and page-turn layout.
3. Open a Link-Panel-only book and confirm its existing random-Age details and destination preview still appear.
4. In a copy of a test world, remove a data pack that supplied one authored symbol. Reopen the book: its original symbol ID should remain readable beside the unresolved artwork, with no writing-notes pages. Restore the pack and verify the page artwork returns without changing the saved Age.
`./gradlew build --offline` verifies compilation and jar assembly. The visual and data-pack-removal checks require an in-game client.
+147 -12
View File
@@ -1,15 +1,18 @@
# GameTest suites
The default dedicated-server run is a small smoke suite: 17 Rivencraft tests
The current Milestone 7 results and manual handoffs are in the
[regression matrix](MILESTONE_7_REGRESSION.md). This file documents the test
mechanics and older scenario-specific handoffs.
The default dedicated-server run is a small smoke suite. Its Rivencraft tests
cover core linking, deterministic Age definition, writing persistence, legacy
data migration, workstation transactions, and forged/replayed menu actions.
NeoForge currently adds two of its own tests, so the runner reports 19.
```shell
./gradlew runGameTestServer
```
The remaining 47 Rivencraft tests are archived, not deleted. They cover
The remaining Rivencraft tests are archived, not deleted. They cover
completed milestone details such as every Age terrain profile, Notebook and
Desk surface interactions, refill edge cases, book assembly controls, and
workstation break/drop behavior. Run only the relevant archive alongside the
@@ -17,11 +20,17 @@ core tests when investigating an issue:
```shell
./gradlew runGameTestServer -ParchivedGameTestSuites=desk
./gradlew runGameTestServer -ParchivedGameTestSuites=linking,binder
./gradlew runGameTestServer -ParchivedGameTestSuites=grammar,binder
./gradlew runGameTestServer -ParchivedGameTestSuites=generation
./gradlew runGameTestServer -ParchivedGameTestSuites=instability
```
Available suite names are `linking`, `writing`, `desk`, `binder`, `mixer`, and
`recipe`. Run all 64 Rivencraft tests (66 including NeoForge's two) before a
Available suite names are `linking`, `environment`, `writing`, `grammar`,
`generation`, `desk`, `binder`, `mixer`, `recipe`, and `instability`. The `generation` archive
keeps the detailed feature, structure, ore, crystal, and fissure regressions
out of the default smoke run. The `instability` archive adds multi-player loaded-chunk selection, policy gates, and protected-block checks without rerunning the default scoring and migration tests. Three checks formerly registered twice in the
core and their archives now run only through the core wrapper. Run all
Rivencraft tests before a
release or after broad cross-system changes:
```shell
@@ -34,9 +43,135 @@ Immersive Portals. The core suite delegates to the original regression methods
for its selected cases, so the two suites do not maintain duplicate test logic.
GameTest Ages are created in `build/gametest-run/world`, not in a development
client save. The Gradle task removes that **dedicated** world automatically if
it exceeds 512 MiB; override the threshold with `-PgameTestMaxWorldMiB=128`,
for example. Cleanup never targets a custom `-PgameTestRunDir` or a player
world. A fresh test world can also help diagnose a slow or stalled worldgen
run. The full suite should still be run before shipping changes in the
archived systems.
client save. Every default GameTest invocation starts with a fresh world.
Minecraft 1.21.1's GameTest server uses the fixed seed `0`. Gradle deletes the
world after every run, whether tests pass or fail. A forcibly killed run may
leave `world` behind; the next run deletes it before starting.
Logs and configuration remain available. Cleanup never targets a custom
`-PgameTestRunDir` or a player world; callers using a custom directory manage
it themselves. The full suite should still be run
before shipping changes in the archived systems.
If a new GameTest stalls the server, stop that run, remove the offending
automated check, and hand its exact scenario to the player for in-game
verification. Do not keep retrying a stalled test or leave its process running.
Keep the narrower non-stalling plan, persistence, and runtime checks automated.
For issue #41, the combined archive and the focused `linking` archive initially
stalled during the multi-profile travel acceptance test. That test is no longer
registered with GameTest; the rerun passed all 116 registered tests before the
subsequent random-biome-count adjustment. The 51-test core suite passes after
that adjustment, but the full archive stalled again late in its first batch;
do not treat that interrupted run as a pass. Verify its
scenario manually: in a fresh world, bind
one Descriptive Book for each of Standard, Flat, Amplified, Island, Cave, and
Void terrain (use the corresponding Terrain Page to select each one). Visit
each Age, confirm a safe arrival platform and headroom, and
travel back using a bound Linking Book. The narrower creation, persistence,
terrain, and travel checks remain automated. See
[random Age weights](RANDOM_AGE_WEIGHTS.md) for the Legacy rank mapping and
the opt-in weighting regression.
Also bind several Link-Panel-only Descriptive Books with no authored biome
pages. Check that each Age retains its biome set after leaving, unloading, and
reopening it, and that the selected biome count varies between Ages without
exceeding the eligible pool. This covers the bounded random-biome path touched
after the last successful full-archive run.
The optional `-PwithSereneSeasons=true` runtime is for a real dev client, not
the GameTest server: Serene Seasons sends a season payload to GameTest mock
players, which cannot negotiate its channel. Follow the real-client Serene test in
`docs/SERENE_SEASONS_COMPATIBILITY.md` instead.
## Manual Ink Mixer modifier check (Milestone 5, issue #36)
With optional JEI installed, search for the Ink Mixer category. It should show
the base paper/ink/vial operation and separate entries for Gunpowder, Mushroom
Stew, Experience Bottle, Ender Pearl, Feather, and Fire Charge,
with their property chances. Black Dye and Clay Ball should not appear: the
former had no working Legacy effect, while Clay Ball remains unsupported by
the mixer. The creative Link Modifier can enable a single arrival platform for
Linking Books; they do not create one by default. Without JEI, Rivencraft should still
start and the mixer should work normally.
Put paper and an Ink Vial in the mixer. Hold Gunpowder on the cursor and
right-click the ink basin once; one Gunpowder should be consumed, and hovering
the basin should show a 20% Disarm chance. Black Dye, Clay Balls, and an
unrelated item such as Stone should remain untouched.
Closing and reopening the mixer should retain the chance. Taking a Link Panel
should consume one paper and one full Ink Vial, clear the current
mixture, and possibly give a panel with Disarm on its tooltip. Repeat until
a property-bearing panel appears, craft it with leather, and confirm the
unwritten Linking Book preserves the property tooltip. After binding it,
link to another dimension; inventory items should drop at the departure point.
With twenty Ender Pearls added to a fresh mixture, both Disarm and Intra
Linking should show about 96.1%, and the basin should animate both property
colors. Taking a panel should reset all three percentages and clear the color
effect; a near-certain modifier should show the brighter outer animation.
Also bind a Descriptive Book with a Disarm panel and check the same drop behavior.
Bind a Descriptive Book with both Intra Linking and Maintain Momentum on its
panel: the assembled book should retain Maintain Momentum but omit Intra
Linking. For Linking Books, try an unmodified book whose destination is the
current dimension: it should refuse travel. A Linking Book with Intra Linking
should work in the same dimension. A Descriptive Book does not require or use
Intra Linking, even if an older book retained that flag.
Automatic Return Links should still appear according to the existing setting
and work from an Age back to the Overworld. A full player inventory or blocked glass-bottle slot
must not duplicate or consume materials for an output that cannot be taken.
Link Panels should not enter Notebooks (folders) or Writing Desk tabs, whether
clicked, shift-clicked, or dragged onto the page surface. An old Notebook or
Desk already holding a Link Panel should still let the player take it out,
but the Desk must not offer a copy of it. The Desk should continue to copy
ordinary symbol Pages; JEI should show that example instead of Link Panel copying.
## Manual Age unload/reload check
The automated suite does not unload a freshly generated Age. Minecraft can
queue point-of-interest block updates after generation; closing that Age's
region storage while a queued update is still pending can leave the dedicated
GameTest server waiting forever in `SectionStorage.readColumn`. Plan save/load,
terrain generation, and safe spawn remain automated; runtime unload/reload is
checked in a normal game session:
1. Back up the test world. Bind one Link-Panel-only Descriptive Book and one
Descriptive Book with a terrain page (for example, Island), using distinct
names. Bring a working way back to the Overworld.
2. Travel through each book, confirm the named Age and expected terrain, then
return to the Overworld. Close book screens and wait a few seconds.
3. With cheats enabled, run `/rivencraft age list` and record each Age's
`rivencraft:age/...` dimension ID. Run `/rivencraft age unload <dimension>`
for one Age at a time, then list again to confirm it is unloaded.
4. Reopen the same Descriptive Books and travel back. Confirm each book reaches
its original Age, the terrain and safe arrival remain intact, and no extra
Age entry was created. Save, restart the world, and repeat the book check.
## Manual feature-generation check (Milestone 5, issue #35)
The automated suite verifies feature pages, compiled and saved feature IDs,
vanilla registry mappings, unsupported combinations, and the runtime generator.
An automated scan across many newly generated chunks stalled the GameTest
server, so physical placement is handed to the player for in-game verification:
1. In a fresh world, bind a Descriptive Book with Link Panel, Standard Terrain,
Plains Biome, Single Biome, Caves, and Rocks pages in that order.
Missing weather/lighting/celestial branches may be seed-filled. Travel to
the Age and explore several fresh surface chunks: boulders made from one
seed-selected full-collision block should appear among the plains terrain.
Then explore underground in fresh
chunks; the Caves page should add a second cave-carving pass to Standard
terrain, not switch the Age to Cave terrain.
2. Make a second book with Flat Terrain and Caves. The Binder should mark the
Caves page unsupported and prevent assembly. Cave Terrain plus Caves should
be accepted; Cave Terrain plus Rocks should be rejected.
3. Return, save, and restart the world. Re-enter the first Age and explore
*new* chunks. The authored decorations should continue without rebinding.
Previously generated chunks are intentionally unchanged.
4. Title-derived terrain regression (with Create installed): with no Terrain Page, assemble Link Panel,
Glowstone, Rocks, Chocolate, Small Lakes in that order and name the Age
`glow`. The Binder should accept it and generate compatible surface terrain;
Glowstone should be the rock material and Chocolate should appear in authored
Small Lakes across fresh chunks. Ordinary vanilla lava lakes may still occur.
Repeat
with `gl` or `glo` to confirm names do not change page validity. An explicit
Flat or Cave Terrain Page with Rocks should still be rejected.
+30
View File
@@ -0,0 +1,30 @@
# Ink Mixer and Link Panel modifiers (Milestone 5, issue #36)
Legacy source: [`InkEffects`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/InkEffects.java), [`TileEntityInkMixer`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/tileentity/TileEntityInkMixer.java), and [`LinkPropertyAPI`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/hook/LinkPropertyAPI.java).
Legacy does **not** use a conventional ingredient slot: the player clicks the ink basin with a carried item. Each accepted item is consumed immediately, changing the basin's property probabilities. A Link Panel is a virtual result; taking it consumes paper and ink, rolls each property independently using a saved next-seed, writes the selected flags to the Page, then resets that basin's mixture. The Link Panel passes those flags to a Linking Book made with leather. The preview must not roll or consume anything.
| Ingredient | Property | Chance per item |
| --- | --- | ---: |
| Gunpowder | Disarm | 20% |
| Mushroom Stew | Disarm | 5% |
| Clay Ball | Generate Platform | 25% |
| Experience Bottle | Intra Linking | 15% |
| Ender Pearl | Intra Linking + Disarm | 15% each |
| Feather | Maintain Momentum | 15% |
| Fire Charge | Disarm | 25% |
| Black dye | Empty effect key; ignored by Legacy's mixer | None in practice |
Legacy also has optional ore-dictionary dusts: brass, bronze, tin, iron, lead, silver, diamond, and gold. Their effect combinations are in `InkEffects.init()`. Forge's ore dictionary does not exist in NeoForge 1.21.1, so these should be mapped through item tags only when a suitable tag and item exist; none should become a hard dependency. The `Relative` and `Intra Linking Only` properties are registered but have no built-in ingredient. `Natural`, `External`, `Offensive`, `Op-TP`, and `Following` are internal link flags, not mixer ingredients.
For each additive, Legacy scales each existing probability by `1 - sum(additive chances)` and then adds the additive's probabilities. Properties are rolled independently on the final Page. The Legacy link effects are: Disarm drops carried inventory before travel; Generate Platform places a single arrival block if a drop exists; Maintain Momentum retains velocity; Intra Linking permits a same-dimension link.
Rivencraft deliberately diverges from Legacy's shared-probability dilution here: each supported modifier accumulates independently as `new = 1 - (1 - old) × (1 - item chance)^item count`. Twenty Ender Pearls therefore produce about 96.1% each for Disarm and Intra Linking, and a panel can receive both. Taking the panel resets every probability to zero. The GUI uses Legacy's empty-basin atlas sprite, fills the basin dark when ink is present, and projects its colored rotating D'ni effect over the ink (red Disarm, blue Momentum, green Intra); the effect intensifies as a modifier approaches certainty and disappears after output is taken.
Implementation requirements: keep the mixer server-authoritative; persist mixture probabilities and next-seed; show a deterministic virtual result; consume exact inputs once; prevent duplicate output on shift-click; preserve inputs when output cannot be taken; copy the Page's link properties into a Linking Book; apply supported properties only after destination validation; retain unmodified books' current safe-link behavior; and expose both base and modifier sequences to optional JEI. Tests should cover persistence, multiple additives, invalid items, full inventory/bottle output, and panel-to-book transfer.
## Rivencraft 0.4.7.x implementation scope
The built-in vanilla-item rows above are supported except black dye and clay balls. Legacy ignores black dye's empty effect key, so Rivencraft does not consume or advertise it in JEI. Clay Balls are omitted because Age arrival already builds a safe platform by default and a separate Generate Platform link modifier would be redundant. Right-clicking the Ink Mixer basin with a carried item adds one; left-clicking adds the carried stack. The mixture is persistent and its next panel is deterministic. The visual basin tooltip shows current probabilities. Only taking the virtual Link Panel consumes paper/ink and clears the mixture. The Link Panel's property component is copied to a Linking Book by the leather recipe or a Descriptive Book by the Binder. JEI's optional Ink Mixer category lists the base panel and each supported additive interaction with its per-property chance; Rivencraft does not require JEI to run.
Disarm and Maintain Momentum are applied by the server after confirming a safe destination. Disarm spawns each inventory stack as an item entity at the departure point, including for Descriptive Books. Intra Linking is required only for a Linking Book whose destination is in the player's current dimension; ordinary cross-dimensional Linking Books still work. The Binder treats an Intra Linking panel as an ordinary Link Panel and omits that flag from the resulting Descriptive Book, while retaining Disarm and Maintain Momentum. Automatic Return Links remain enabled under the existing configuration, as requested. The Ink Mixer still does not roll Generate Platform, but since 0.6.7.1 the creative Link Modifier can explicitly enable it for Linking Books; their default remains off. Optional dust-tag mappings remain outside this built-in subset until their cross-mod tags and gameplay meaning are agreed.
+13
View File
@@ -0,0 +1,13 @@
# Colored decay (Milestone 6, issue #68)
Legacy defines seven decay block-state colors and has a texture/model for every one: black, red, green, blue, purple, yellow, and white. That does not mean all seven are active instability hazards. Its active provider deck registers blue, purple, red, and white. Black has a decay handler and provider class, but its provider registration is commented out; green and yellow have visual states without registered spread handlers. Rivencraft currently implements the four active provider colors. Their 16×16 textures are exact copies of the local Mystcraft 1.12.2-0.13.7.06 assets at `assets/mystcraft/textures/blocks/decay_{blue,purple,red,white}.png`. Legacy's `blockdecay.json` and `blockdecay_*.json` map these to cube-all models; Rivencraft uses the same mapping.
The local Legacy bytecode (`EffectDecayBasic`, `DecayHandlerSpreading`, and the four color handlers) seeds decay in a chunk, then spreads to six neighbors. Blue prefers softer blocks, red uses explosion resistance, purple is slower, and white converts almost anything and damages entities on contact. Rivencraft preserves the four colors, six-neighbor growth, relative conversion tendencies, and white contact damage. It adapts the exact difficulty formulas to modern block hardness and safety checks; fluids are not replaced, and provider attempts are deterministic from the Age seed and persisted attempt counter.
Decay requires both `instability.world_mutation=true` and `instability_decay.enabled=true` in the server config. Both now default to true, matching Legacy's enabled-by-default Age instability and provider registration. Existing config files retain their saved values, including `false`; change them explicitly to opt in after updating an existing installation. At most `instability_decay.max_attempts_per_call` neighbors (default four) are examined, each within radius one, and at most one block can be replaced per provider run. The shared Age action budget may lower that further. Only chunks already loaded and supplied by the scheduler are touched; no cross-dimension or force-loading path exists. A per-Age `rivencraft_decay` saved data file stores a bounded frontier (1024 positions per color) and attempt counter. Disabling decay stops seeding, spreading, and white contact damage; existing blocks remain so re-enabling can resume.
Decay rejects the saved Age spawn and its nearby 5×5 arrival platform (with a two-block margin), block entities, fluids, unbreakable blocks, vanilla portal/command/structure infrastructure, blocks in `#minecraft:portals`, all non-decay Rivencraft blocks, and blocks in `#rivencraft:decay_protected`. Data packs can append other mods' protected blocks to that tag. A fake-player break event is posted before replacement, and a placement event after it; a cancellation prevents or restores the change. As with other fake-player automation, protection mods need to handle NeoForge block events for this safeguard to apply.
GameTests cover persistence, frontier deduplication, selected-chunk limits, and Overworld isolation. A real-client check should enable both settings in a disposable Age, observe growth, leave/reload that Age, then disable decay and verify growth stops. Also test a claim-protected block and a block entity if a protection mod is installed.
For current test builds, an operator standing in an Age can use `/rivencraft age decay pulse white 32` (or `blue`, `purple`, `red`). This performs at most 32 budgeted provider calls in the player's already-loaded chunk and reports how many blocks converted. It does not edit the saved Age plan or instability score. Repeat the command to observe spread, leave/reload the Age to check persistence, then disable decay and confirm the command refuses to act. This test hook remains useful because the provisional authored-page scoring does not yet provide a reliable route to the Legacy eating deck's 15,000-point gate.
+11
View File
@@ -0,0 +1,11 @@
# Instability controls and diagnostics (Milestone 6, issue #72)
The world-specific NeoForge server config (`serverconfig/rivencraft-server.toml`) is the global policy. `instability.enabled` stops all scheduled effects without changing a saved Age. `instability_providers.<id>` switches each Legacy card independently; these switches do not reroll frozen provider levels. The existing `instability_status_effects`, `instability_decay`, `instability_hazards`, `instability_meteor`, `instability_scorching`, and `instability_erosion` groups provide category policy and action limits. `instability.world_mutation`, hazard/meteor `block_damage`, `player_damage`, and `fire` settings control destructive behavior. `mobGriefing` and `doFireTick` still apply. Defaults retain the Legacy-active hazards; the scheduler, spawn protection, loaded-chunk checks, and per-effect caps remain Rivencraft safety adaptations. The dormant-in-Legacy erosion effect remains enabled for project testing but can be switched off.
Configuration values are read during server operation, but editing the TOML on disk does not guarantee NeoForge will reload it. Restart the server or world after changing the file; `/reload` reloads data packs, not this server config. Per-Age commands below take effect on the next scheduler pass without a restart. A stopped provider does not refund or change the Age's score or saved plan. Existing owned status-effect leases are revoked on their next 20-tick check after global, category, provider, or Age disablement. In-flight meteors are cancelled when their Age or meteor provider is disabled. Existing block changes are not rolled back.
Operators can inspect `/rivencraft age instability [dimension]`. The argument accepts an Age name (quote names containing spaces), a short path such as `fort`, or a full ID such as `rivencraft:age/fort`. Duplicate names require the full ID shown by `/rivencraft age list`. The command reports the frozen score and each saved contribution, the frozen provider IDs/levels and their *current* runtime eligibility, config limits, and whether this Age is enabled. "Eligible" does not promise an effect on the next tick: chance, cooldowns, loaded chunks, player presence, and protection still apply. An unavailable handler is reported as such instead of being mistaken for an active provider. Pre-scoring Ages remain neutral, and missing provider plans from older scored saves are migrated once as described in [runtime policy](INSTABILITY_RUNTIME.md). `/rivencraft age instability disable <dimension>` and `enable <dimension>` are operator-only and persist for that Age alone. Unknown dimensions are rejected. Neither command mutates a book, score, or provider plan.
Newly bound Descriptive Books carry only a server-written numerical potential-instability summary for their tooltip and cover. The details and live enablement stay server-side in the operator command. Older written books receive the summary when opened through the server-approved book screen; until then, an absent tooltip score means "not yet synchronized," not zero. The score does not indicate whether effects are currently enabled.
Manual check: create two scored Ages, disable one with the command, and inspect both. Only the selected Age should show "Age disabled"; its saved breakdown and plan should remain identical. Re-enable it, then switch an individual provider in the world config and restart. Its plan should remain identical while diagnostics show "provider config off." An old neutral Age should report score zero and no selected providers. A non-operator should be unable to invoke the mutation commands. Test tooltips both on a newly bound book and an older book after opening it once.
+15
View File
@@ -0,0 +1,15 @@
# Environmental instability (#70)
The local Mystcraft Legacy 0.13.7.06 bytecode was audited for `EffectMeteor`, `EntityMeteor`, `EffectScorched`, `EffectErosion`, and their providers. The active deck registers `meteors` in destructive and `burning` (scorching) in harsh. Erosion's provider is present in bytecode but not registered in the active deck. Its old chunk-population lake routines are not run at Age creation in Rivencraft; ordinary authored lake features already own generation. The old erosion tick spread a neighboring water/lava or Forge fluid block into a random non-liquid position, excluding downward neighbors and block containers.
Rivencraft keeps the active `meteors` and `burning` provider IDs in frozen Age plans. Erosion does not become a deck card. For this test slice, `instability_erosion.enabled` defaults **on** as an explicit adaptation requested by the user. It runs only in Ages whose frozen instability score reaches the destructive deck threshold (10,000), and may be disabled independently. Existing Age plans are not rerolled.
Legacy's meteor spawned at Y=500, fell as a custom entity, broke blocks along its path, and made multiple large explosions on impact. Modern Ages can have different build heights and dynamic dimensions. Rivencraft instead records one bounded active descent per Age (16 globally), starting no more than 48 blocks above the loaded surface. Server particles show the trajectory and the byte-identical Legacy `entity/meteor/roar.ogg` plays near the impact. A single strength-3 impact reuses the capped explosion/protection hooks from #69. There is no persistent projectile entity or custom model; stopping/unloading a dimension cancels a descent rather than replaying it on restart. The saved per-Age attempt sequence and cooldown prevent immediate repeated rolls. The adaptation is intentionally less destructive than Legacy's chain of explosions.
Scorching checks at most eight loaded chunks and eight living-entity candidates per chunk per scheduler pass, igniting at most the configured target count. Targets must be open to sky; survival/adventure players are included by default as in Legacy, while creative/spectator players and spawn safety positions are excluded. Fire duration is four seconds per provider level, capped at twelve seconds. This affects entities, not blocks, and does not request distant chunks.
Erosion inspects at most eight already-loaded candidate positions per pass and can replace at most one safe block with an adjacent source fluid. It excludes block entities, protected/critical Rivencraft blocks, portals, spawn safety positions, and unloaded neighboring chunks. Break and place events with a fake player permit claim/protection hooks to veto a change; rejected placement is rolled back. World mutation and `mobGriefing` must also be enabled. The modern Y range is taken from the Age level rather than Legacy's fixed 0–127 sample.
The server config has independent meteor enable/block damage/player damage/fire/caps, scorching enable/player targeting/cap, and erosion enable switches. Meteor frequency preserves Legacy's 1/50,000 chance per chunk-tick across nearby loaded chunks, capped to one active descent per Age. Visual/audio effects still run when meteor block damage is disabled. No client-only class is loaded on a dedicated server. The reused roar sound is recorded in [asset provenance](ASSET_PROVENANCE.md); Legacy has no separate meteor texture in the supplied asset tree.
Operator checks in a loaded, sufficiently unstable Age: `/rivencraft age environment pulse meteor` starts one protected descent (200-tick cooldown), `/rivencraft age environment pulse burning` ignites the issuing survival player only when open to sky and outside spawn protection, and `/rivencraft age environment pulse erosion` converts the block being looked at if a source fluid touches its side or top. The burning test reports when rain or water would immediately extinguish the fire. Place water or lava next to a plain stone block and aim at the stone to test erosion. These commands refuse to run in the Overworld and still honor enable switches, protection hooks, and world-mutation policy. Natural meteor occurrence is intentionally rare, as in Legacy.
+30
View File
@@ -0,0 +1,30 @@
# Instability provider runtime (Milestone 6, issue #66)
Each new Age stores an `InstabilityPlan` beside its frozen `Instability` score. The version-1 plan contains provider IDs and levels. Selection uses the saved score rounded down to a 500-point step and the Age seed. It independently walks Legacy's basic, harsh, destructive, eating, and death decks with their audited entry costs (0, 2,500, 10,000, 15,000, 20,000), shuffling each deck deterministically before paying its weighted card costs. The source card weights and default provider costs are copied from the user's local Mystcraft 0.13.7.06 `InstabilityData` class. This preserves Legacy's multiple-deck behavior, but does not claim byte-for-byte reproduction of its historical Java RNG state, difficulty multiplier, config overrides, or 400-chunk block profiling delay.
The saved provider IDs are independent of installed handlers. A missing optional handler leaves its ID and level intact and is skipped at runtime. A syntactically invalid saved ID or unsupported plan version fails loading explicitly; an unknown but valid ID is preserved and logged once when encountered. Disabling instability or world mutation never rewrites a saved score or provider plan. Saves from #64 with a frozen score but no provider plan are selected once from that score and Age seed, then marked dirty for persistence. Older pre-score Ages remain neutral and inert.
From 0.5.8.11, [live block profiling](LIVE_BLOCK_INSTABILITY.md) adds a separate
effective score and provider plan once 400 Age and Overworld chunks have been
sampled. The authored score/plan described above remain saved as the base;
the effective provider selection can change as block abundance is observed.
The server scheduler runs only for loaded `rivencraft:age/*` levels with a player present and an Age definition. It uses only chunks already loaded near those players, never requests a chunk, and rotates among eligible Ages when the per-tick cap is reached. Configuration bounds the cooldown (minimum 20 ticks), active Ages, handlers and actions per Age, effective provider level, and examined loaded chunks. A handler must claim a shared action permit before each effect. A throwing handler is logged and quarantined temporarily without stopping the server. World-mutating handlers are enabled by default to match Legacy; the setting can still disable them. These limits are guards for upcoming providers, not proof that an arbitrary third-party handler cannot misuse world APIs.
Issue #67 registers the non-world-mutating potion handlers; see [status-effect policy](INSTABILITY_STATUS_EFFECTS.md). Issue #68 adds configurable [colored decay](INSTABILITY_DECAY.md), enabled by default for newly generated configs. Issue #69 adds lightning and explosions; issue #70 adds [meteor, scorching, and erosion](INSTABILITY_ENVIRONMENTAL.md). The runtime rejects Overworld and preview dimension IDs; its player requirement excludes unattended snapshot chunk loaders. GameTests cover deterministic selection, save round-trips, score-only migration, unknown IDs, scope checks, fair admission, and action caps.
Issue #72 adds [server config, per-Age controls, diagnostics, and book feedback](INSTABILITY_DIAGNOSTICS.md). Its operator controls never rewrite a frozen score or provider plan.
The Milestone 6 [regression and migration record](MILESTONE_6_REGRESSION.md)
maps the automated acceptance checks and lists the in-game restart, optional-mod,
and hazard-visual scenarios still requiring verification.
# Lightning and explosions (#69)
The `lightning` and `explosions` cards now run only in loaded Rivencraft Ages with players. Legacy rolled once per chunk per tick: explosions at 1/1000 and lightning at 1/5000 during thunder or 1/100000 otherwise. Each selected hazard card gets an independent roll every scheduler pass, rather than waiting for the rotating two-provider sample. The roll uses up to 81 already-loaded chunks near Age players and the exact probability of at least one Legacy opportunity over that many chunk-ticks. It permits at most one event per hazard type per pass. A per-Age saved sequence and last-event time prevent the same roll/event from replaying after a restart. Lightning also skips an existing bolt at the target.
Legacy used a real lightning bolt and a flaming, block-damaging strength-3 explosion. Both remain enabled by default. Unlike Legacy, the bolt is visual-only and its damage/fire are applied through separate bounded paths. The explosion uses NeoForge's normal explosion hooks plus break-event checks, with a calculator that restricts destruction to one already-loaded chunk, protects spawn, block entities, portals, decay-protected blocks and Rivencraft infrastructure, and caps affected blocks and entity damage. The visual/audio explosion still occurs when griefing is disabled. `mobGriefing` and `doFireTick` gate block changes and ignition; survival/adventure player damage is separately configurable. These are deliberate modern server-safety differences from Legacy.
Server config `instability_hazards` controls enablement, block damage, player damage, fire, interval, maximum nearby loaded chunks, maximum destroyed blocks, damaged entities, damage per entity, and explosion radius. `instability.world_mutation=false` also suppresses block damage and fire without suppressing visuals or entity damage. No hazard is dispatched into preview, Overworld, or another Age.
For an operator smoke test inside an Age, `/rivencraft age hazard pulse lightning` and `/rivencraft age hazard pulse explosion` force one event in a safe loaded chunk. They still honor the Age scope, enable switch, cooldown, spawn exclusion, game rules and damage policies. Repeat after `minimum_gap_ticks` (default 20 ticks); run the same command in the Overworld to verify that it refuses to fire. The earlier 200-tick `min_interval_ticks` test key is retired; existing configs receive the new default when NeoForge corrects them.
+21
View File
@@ -0,0 +1,21 @@
# 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.
The score is calculated after the Age plan and effective traits are chosen, then saved with the Age identity before its dimension is generated. The same UUID returns the existing definition, including the same score, on repeated use. Spawn updates preserve it. Reload only deserializes the frozen breakdown; it never reruns the current grammar or catalog. An older Age definition without `Instability` is migrated to a persisted version-1 neutral score of zero, rather than retroactively scoring its writing. A future scoring-rule change must introduce an explicit new schema/migration policy, not silently reinterpret existing Ages. Issue #66 added the frozen provider selection; issues #67–#70 added the current handlers. See the [runtime policy](INSTABILITY_RUNTIME.md).
This is now the **authored base** score. [Live block profiling](LIVE_BLOCK_INSTABILITY.md)
keeps its sample counts separately and may add a changing effective block score
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 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.
+9
View File
@@ -0,0 +1,9 @@
# Age status effects (Milestone 6, issue #67)
This slice activates the Legacy potion cards selected and saved by issue #66. The user-provided Mystcraft 0.13.7.06 `EffectPotion` chooses a random living entity in a chunk section. A local card requires sky visibility; a global card does not. `EffectPotionEnemy` excludes players. Its amplifier is selected level minus one. The defaults in `InstabilityData` give blindness and nausea 60 ticks, mining fatigue/hunger/poison/slowness/weakness 80 ticks, wither 30 ticks, and enemy regeneration/resistance 200 ticks. Rivencraft preserves those durations, sky rules, and level-to-amplifier mapping, subject to the server's lower duration/level caps.
General debuffs can affect survival/adventure players and other living entities, as Legacy did. The two enemy buffs affect hostile mobs only; Legacy affected any non-player living entity, so excluding passive animals is a deliberate modern adaptation. Creative and spectator players are exempt per issue #67, although the inspected Legacy potion class did not have that exemption. Existing effects from vanilla or another mod are never overwritten.
The issue #66 scheduler chooses an already-loaded chunk near a player, at most the configured number of provider calls and action claims per Age run. This provider queries at most `max_candidates_per_attempt` living entities in that chunk, picks one deterministically from the bounded candidates, and claims an action before applying. It holds at most `max_active_per_age` short-lived, owned-effect leases. It neither force-loads chunks nor scans all entities or chunks. Exact target RNG therefore intentionally differs from Legacy's random chunk-section walk; the target types, local/global distinction, duration, and strength are retained.
The server config can disable all status effects, player targets, general non-player targets, or hostile buffs, and can lower active leases, candidates, and duration. Leaving an Age, logging out, disabling effects, or stopping the server removes only an effect instance this provider actually installed; it will not remove a replacement from another mod. Leases also expire automatically after their short duration. The runtime never runs in the Overworld or a preview dimension. Automated tests cover mappings, target eligibility, creative/spectator exemption, and Overworld isolation. In-game multiplayer, Age-to-Age travel, and restart checks remain useful because GameTests do not create a second dynamic Age for this slice.
+1 -1
View File
@@ -18,7 +18,7 @@ Legacy does **not** reject a book just because it omits terrain, biome, weather,
| `WeatherNorm`, `Fast`, `Slow`, `Off`, `On`, `Cloudy`, `Rain`, `Snow`, `Storm` | `AgeTraits.Weather` has matching names except Legacy `On` maps to `ALWAYS` | Compile weather semantics separately from biome selection. `Snow` must remain deferred until optional season integration; do not silently substitute another weather mode. |
| Suns, moons, starfields; angle, phase, period, color/gradient/sunset modifiers | One `celestialPeriod`, `celestialAngle`, `celestialPhase` on `AgeTraits` | Compile only the representable single-cycle subset first. Multiple celestial bodies, independent periods, custom stars, gradients and sunsets need a richer definition and renderer, not flattening into one value. |
| Sky/fog/cloud/grass/foliage/water colors, natural variants, horizon, rainbow | Existing sky/dimension visuals and preview | Require an explicit modern client/biome-color design; metadata-only symbol pages must not pretend to change rendering. |
| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile and vanilla data-pack generation | Compile only features for which registered 1.21.1 placed features/structures exist. Defer bespoke structures and polish to milestone 7. |
| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, authored Floating Islands/Tendrils/Skylands/Spheres/Spikes/Obelisks/Crystal Formations/Star Fissures and Dense Ores, and page-gated vanilla Dungeons/Mineshafts/Nether Fortresses/Strongholds/Villages | The five Legacy vanilla structure symbols gate Minecraft 1.21.1 generation. Dungeons use monster-room placement; the others use vanilla structure sets. Obelisks use a bespoke 4×4 plinth and 2×12×2 shaft with safe modern placement. Crystal Formations retain Legacy's blue tile and one-in-fifteen attempt rate but use new budding, shard-producing mounds. Star Fissures reproduce Legacy's spawn-chunk footprint, open-to-sky columns, home link, and sound. Their renderer uses the modern End Portal pipeline in place of Legacy's retired fixed-function OpenGL calls. Exact 1.12 geometry for other structures remains separate work. |
| `EnvAccel`, explosions, lightning, meteors, scorching; PvP and dummy suppression pages | No equivalent Age-definition effect | Defer environmental hazards/instability effects to milestone 6, PvP rule to a separately scoped server policy; retain authored IDs without fake behavior. |
The Legacy symbol list and page names come from [ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L129-L240) and [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L19-L137). Rivencraft's [catalog](../src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java) currently holds display metadata only; its built-in set is just standard terrain, native biomes, normal weather, normal lighting, normal time, and caves. [OrderedWriting](../src/main/java/net/dnorberg/rivencraft/writing/OrderedWriting.java) preserves page order, but no compiler yet connects it to [AgeTraits](../src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java) or [Age profiles](AGE_PROFILES.md).
+77
View File
@@ -0,0 +1,77 @@
# Mystcraft Legacy instability audit (Milestone 6, issue #63)
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.
| Input | Verified Legacy behavior | Rivencraft M6 interpretation |
| --- | --- | --- |
| Missing required controller | Terrain, biome controller, weather, and lighting are generated when missing. `InstabilityData.missing.controller = 0`; lighting explicitly adds zero. Missing pages themselves are not a penalty. | Keep Age-seeded completion. No penalty solely for an omitted branch. |
| Repeated same symbol | `addSymbol` increments a per-`IAgeSymbol` count and adds that symbol's `instabilityModifier(count)`. This method is symbol-specific; there is no universal “duplicate = N” rule. | Distinguish legal repeated features/materials from repeated single-choice pages. Do not add a flat duplicate penalty to every occurrence. |
| Extra/conflicting controller | Registering a second terrain, biome, weather, or lighting controller adds `InstabilityData.extra.controller = 500`; the **last registered** controller replaces the prior one. This is separate from each symbol's own modifier. | Eligible supported conflicts may become unstable rather than fatal in #65, with deterministic winner and a saved penalty. |
| Dangling modifier | An unconsumed modifier contributes its `dangling` value; a default `Modifier` has `dangling=100`. Replacing a pending modifier charges the old one. `clearModifiers()` charges 20% (`clearPercentage=0.20`) of each pending value, with Java integer compound-assignment truncation. | Record the ordered offending page and a reason; calculate a deterministic penalty from the consumed/leftover modifier semantics, not from GUI position alone. |
| Unsupported/unmatched page | Legacy logs unmatched symbols during reconstruction and skips their logic; its `AgeData.getBaseInstability()` still has a TODO for calculating instability from pages. This is not a sound compatibility contract. | Remains fatal for *new* books until semantic and runtime support exists; never silently erase the page or pretend its effect applied. Existing saved books retain their IDs. |
| Other penalties/bonuses | `AgeController.addInstability(int)` is available to symbol logic. The base/book score is persisted as a `short`; Legacy's [`InstabilityBonusManager`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/bonus/InstabilityBonusManager.java) also has dynamic player death/behavior components. | Persist a versioned score breakdown for Age writing and environmental contributions. Do not infer death/troll mechanics from the mere existence of Legacy classes. |
The complete Legacy `instabilityModifier` audit found direct feature-page costs only for Mineshafts, Nether Fortresses, Strongholds, and Villages. Their first three occurrences cost zero; the fourth and every later occurrence adds 100. Every other Legacy feature class inherited `SymbolBase`'s zero modifier. Rivencraft applies that exact rule to the four matching pages. By project balance decision, Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins use the same repeat rule. Matching Legacy features such as Caves, Crystal Formations, Dense Ores, Dungeons, Floating Islands, Huge Trees, Deep/Surface Lakes, Obelisks, Ravines, Skylands, Spheres, Spikes, Star Fissures, and Tendrils have no direct page cost. Legacy could still derive instability later from excess profiled blocks, especially ores. From 0.5.8.11, Rivencraft has a [bounded live block-profile adaptation](LIVE_BLOCK_INSTABILITY.md) using the 400-chunk threshold, including diamond blocks as a deliberate non-Legacy extension. Other modern-only Rivencraft features remain neutral unless they increase watched-block abundance.
Bright Lighting adds 500 for every authored occurrence. A generated Bright Lighting controller also adds 500. This is independent of the 500-point extra-controller cost, so two authored Bright Lighting pages produce 1,500 total: 500 for each symbol and 500 for the repeated controller.
Source for controller and modifier defaults: [`InstabilityData`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityData.java), [`AgeController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java). The local 0.13.7.06 `Modifier.class` confirms `dangling_default=100`. Legacy grammar **ranks** weight which missing symbols are selected; they are not a direct instability score or deck tier. See [grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) and [random weights](RANDOM_AGE_WEIGHTS.md).
[`InstabilityController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityController.java) rounds its working score toward zero to the current smallest registered positive provider cost (500 by default), then rebuilds active effects when that rounded score changes. Each seeded deck preserves its saved card order; new cards are shuffled with the Age seed. For each deck it subtracts the deck entry cost, then walks cards in order, paying each provider activation cost until the next card cannot be afforded. A provider's occurrences become its level. **Deck costs are gates, not mutually exclusive severity bands**: at high score multiple decks can contribute effects. Provider `.enabled` config can remove cards. The actual effect set cannot be inferred from a score alone without the saved deck orders and enabled providers ([`Deck`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/Deck.java), [`InstabilityManager`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityManager.java)).
| Legacy deck | Entry cost | Examples of registered cards |
| --- | ---: | --- |
| basic | 0 | enemy regeneration/resistance, fatigue, hunger, poison, slowness, weakness |
| harsh | 2,500 | stronger/global potions, burning, lightning, wither |
| destructive | 10,000 | crumble, explosions, lightning, meteors, wither |
| eating | 15,000 | colored decay, blindness, nausea, wither |
| death | 20,000 | global blindness/nausea/wither, colored decay |
These entry costs are from [`InstabilityData.initialize()`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityData.java), not guaranteed first-activation scores: each card also costs 500–5,000 depending on provider. A zero-cost basic deck still needs enough score to buy its first card. `stability.*` numbers in that file are provider activation costs, despite the misleading field name.
## Effect inventory and modern policy
[`InstabilityData.initialize()`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityData.java) is the authoritative list of *registered* providers. Some implemented classes are commented out there; their existence does not mean they occur normally. Each Legacy environmental effect is called for chunks that the Age's runtime processes, often with its own chance. No per-Age global work budget is visible in these effect classes.
| Family | Legacy behavior | M6 disposition |
| --- | --- | --- |
| Player and hostile-mob potions | Local/global blindness, fatigue, hunger, nausea, poison, slowness, weakness, wither; hostile regeneration/resistance ([`EffectPotion`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectPotion.java)). | Implemented in #67 with server-only, Age-scoped entity targeting and rate limits. No effects run in preview/snapshot worlds. |
| Colored decay | Blue, purple, red, white providers place advancing decay and extra-tick it; black provider is commented out. Decay handlers differ in spread/consumption ([providers](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/providers), [handlers](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/decay)). | Implemented in #68 as bounded world mutation with protected spawn/portal areas and configurable shutdown. Black/green/yellow remain excluded because they are **not** proven active Legacy providers. |
| Lightning and explosions | Random per-chunk strike/explosion attempts ([`EffectLightning`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectLightning.java), [`EffectExplosions`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectExplosions.java)). | Implemented in #69 with separate damage/fire/griefing policies and loaded-Age bounds; see `INSTABILITY_RUNTIME.md`. |
| Meteor, scorching | Meteor entity spawn and sky-exposed entity ignition ([`EffectMeteor`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectMeteor.java), [`EffectScorched`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectScorched.java)). | #70 implements a bounded meteor descent/impact and the active `burning` card; see `INSTABILITY_ENVIRONMENTAL.md`. Legacy `meteor/roar.ogg` is reused. |
| Crumble and erosion | Crumble maps blocks to weaker states; erosion spreads adjacent fluids and has extra lake-generation methods ([`EffectCrumble`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectCrumble.java), [`EffectErosion`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectErosion.java)). | #70 implements guarded fluid spread. Erosion's provider is absent from the active Legacy deck; it is deliberately enabled by default in this test slice without adding a saved deck card, per user request. |
| Extra ticks | Registered alongside blue/purple/red/white decay, targeting decay blocks ([`EffectExtraTicks`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectExtraTicks.java)). | Implement only as a bounded decay helper in #68; do not accelerate arbitrary blocks/block entities. |
| Other dormant code | Black decay, global burning, bedrock crumble, erosion provider are commented out; ordinary `burning` (scorching) remains active. | Erosion has been explicitly requested for #70 testing. The other dormant variants remain deferred; an authored symbol requiring one must fail clearly until supported. |
Assets in the supplied local tree: `assets/mystcraft/textures/blocks/decay_{black,blue,green,purple,red,white,yellow}.png`, corresponding decay block/item model JSON, and `assets/mystcraft/sounds/entity/meteor/roar.ogg`. Before importing, review attribution/license and fit modern 16×16 assets. Texture availability does not make an effect active. Use current Minecraft effects/sounds where adaptation is cleaner; no new artwork is needed for this audit.
## Rivencraft boundary and current safety contract
[`WritingGrammar`](../src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java) classifies `INCOMPLETE` as seeded completion and supported writing defects as accepted instability. `AgeGenerationPlan.compile()` freezes the original writing, repaired active-page indexes, and a provisional writing-instability score before world generation. #64 persists the versioned score breakdown, and #66 derives and persists the provider/deck choice from that frozen score. The plan must not mutate an already-created Age when a data pack changes. Server-side settings may disable execution, but do not rewrite the saved authored score or provider plan.
For #65, **eligible-to-become-unstable** means a known, supported page whose semantics and resulting world are still deterministic: repeated/conflicting single-choice pages and otherwise valid out-of-order or misplaced pages. The implementation retains every original page, uses the last single-choice page, sorts active categories, and saves 500 per extra/conflicting choice or 100 per misplaced page. Issue #71 adds the audited Bright Lighting and repeat-structure modifiers described above. Missing branches are explicitly classified as zero-cost seeded completion; legal repeated feature/material pages are not automatically errors, though a fourth matching Legacy structure now receives its source-defined cost. **Remain fatal**: missing/duplicate/misordered Link Panel, over-capacity book, unresolved or schema-v1 display-only symbol, unsupported weather/angle/runtime profile/biome/feature, unavailable required optional mod, and any combination the compiler cannot represent safely. Binder and unbound-book previews distinguish blocked writing, seeded completion, and accepted instability before binding; the server revalidates on output. This intentionally differs from Legacy's silent unmatched-symbol skip, universal controller cost assumptions, and modifier-specific dangling costs.
Legacy includes `PlayerKilledBonus` and `PlayerTrollPenalty` classes, but the pinned source contains no default provider registration for either. Rivencraft does not activate death, PvP, player-presence, or cross-dimension credit from dormant classes. Consequently #71 has no mutable per-Age bonus state to migrate, cap, reset, or persist: the stability bonus and runtime penalty are both zero, and the effective score equals the frozen base score. `/rivencraft age instability [dimension]` reports those four values and the disabled policy explicitly. This prevents unrelated players, deaths in other dimensions, and repeated deaths from changing an Age. A future opt-in policy must use separate versioned saved data and define its cap/reset rules before it can alter runtime selection.
Automated #65 checks cover classification, preserved input slots on rejection, and an accepted unstable preview. The live output/one-time Age creation check is assigned to in-game testing because the GameTest storage phase stalled when that transaction ran; the test world is not retained.
Issue #66 established a server-only scheduler with bounded per-Age actions, provider calls, levels, cooldowns, loaded-chunk candidates, and rotating active-Age admission. Issues #67–#70 registered the current status, decay, lightning, explosion, meteor, burning, and erosion handlers with protected spawn/link platforms, portal exclusions, and provider-specific damage or spread limits. Instability and world-mutating handlers are enabled by default for newly generated configs to match Legacy's active default; operators can disable them without changing a saved Age. The bounds and protection hooks are **Rivencraft safety adaptations**, not Legacy defaults. Score breakdown, selected effects, enablement, and budget suppression remain part of diagnostics issue #72. See the [runtime policy](INSTABILITY_RUNTIME.md).
M6 owns scoring, selection, effects, controls, and regression. M7 still owns portal/transport and broader polish; M6 must not depend on new M7 structures. An illegal biome/terrain combination that is presently unrepresentable remains a generation failure until a separate, safe compiler path exists; instability must not be used to disguise absent worldgen support. Existing pre-release saves need explicit migration behavior in #73 rather than a silent rescore.
## Verification and open questions
- Inspected pinned source for `AgeController`, `AgeData`, `GrammarTree`, `InstabilityData`, `InstabilityManager`, `InstabilityController`, `Deck`, all active provider families, effect classes, `ChunkProfiler`, and `InstabilityDataCalculator`; checked matching local 0.13.7.06 class names and `Modifier.dangling_default` with `javap`.
- The supplied extraction has no generated user balance config; registered defaults can be overridden or providers disabled. There is no single universal instability value for “wrong order” or “duplicate page.” `IAgeSymbol.instabilityModifier(count)` implementations and runtime configs determine the final Legacy value.
- Exact Legacy visual/decay behavior, dynamic bonus registration, and full 1.12 block-baseline parity need focused checks when their slices are implemented. Do not label proposed modern caps as source facts.
+67
View File
@@ -0,0 +1,67 @@
# 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
Issue #77 now has a craftable Book Receptacle (eight Crystal Blocks around an empty center), a bounded loaded-chunk crystal-network validator, persistent book holder/source pointers, animated Legacy portal cells, and portal collision routed through the existing Linking/Descriptive Book travel services. A book is only taken after a nonempty frame plan and destination dimension are validated. Removing the book or breaking its support removes the portal; portal cells validate on neighboring block changes, like vanilla portals, rather than self-destructing on recurring ticks during chunk loading. The Descriptive Book path preserves the configured automatic Return Link policy and link modifiers. Crystal-portal arrival does not generate a landing platform, matching Legacy's external travel semantics. A normal Linking Book still needs Intra Linking for same-dimension travel.
When Immersive Portals is present, render-only, non-teleporting IP portal entities show the book's destination through both faces of each coplanar opening; individual crystal cells are combined into one shaped surface so the destination is not repeated tile by tile. The view's central camera is aligned to the same safe arrival position and yaw/pitch used by book travel (at standing eye height). Perspective still changes naturally as the observer moves in front of a live portal. Each live view is recessed inside the opening; the animated fallback texture stays visible until the live view is established, and crystal blocks render after the IP view so the frame appears on top. Without IP, the animated Legacy portal texture remains visible. Rivencraft portal-block contact remains the travel authority. This is an optional compile-only integration. The first placement of a Descriptive Book can generate/load its Age to establish a view destination. If the optional view is absent after restart, the holder's maintenance pass recreates it. Portal contact uses IP's instant transfer only if the destination's 3×3 chunk area was already loaded; otherwise it uses vanilla Minecraft's dimension transfer.
Issue #78 hardening: placing each new portal cell fires a NeoForge entity-place protection event attributed to the player who inserted the book. If any cell is rejected, portal placement rolls back and the book remains with the player. Cells are placed without intermediate neighbor notifications so the frame validator cannot remove the first cells before the whole opening exists. Unattended maintenance never generates missing cells or tears down a saved portal during restart: block-change validation handles local frame damage, while the book remains retrievable. Portal owner positions are NBT integer arrays (not compound tags); the reader now matches that format, live-view state changes preserve the block entity's owner, and an intact saved surface with ownerless cells can recover its pointers from the adjacent book receptacle without placing new blocks. A missing neighboring chunk is treated as unknown, not as a broken frame. Portal cooldowns are per-player and session-only, never saved against a server tick counter that resets on restart. A Descriptive Book portal now searches for existing safe footing without creating a platform; Linking Books retain their intentionally floor-optional destination rule. Missing destination dimensions keep the book retrievable and remove any stale Immersive Portals view. Portal blocks and crystal frames are explicitly decay-protected. Book-GUI preview chunk loaders close on GUI exit, travel, logout, and server stop, with a five-minute fail-safe if a release packet is lost; crystal portals themselves add no Rivencraft chunk tickets.
Manual #78 matrix (use two players where noted): restart with an active portal and confirm the book, surface, and destination view return once its chunk loads; walk through it immediately and after 10 seconds without reinserting the book. Then unload/reload the Age and repeat. With destination chunks loaded, contact should transfer instantly; with them unloaded, it should use Minecraft's normal dimension transfer while the animated fallback remains visible until the live view is ready. Have both players enter the same portal, return through separately bound books, and confirm each cooldown is independent and neither loops immediately. Test a Descriptive Book destination with unsafe feet/head space and with no existing footing: it must choose another footed spot or refuse travel, never generate a platform. Deny a portal cell with a block-protection mod and confirm activation consumes no book and leaves no partial surface. Break one cell and confirm it does not silently reappear. Remove or unload the destination dimension and confirm no crash or stale live view, while the book can still be retrieved. Open a book preview with Immersive Portals, close it, and confirm its temporary chunk loader is released; an abandoned preview should expire within five minutes. Repeat with Immersive Portals absent to confirm the fallback texture and book travel.
Manual #77 check: build a 3×3 vertical Crystal Block ring, put the Receptacle on one frame crystal's outward face, then insert a bound Linking Book. The center should animate and link on contact; the book stays in the Receptacle. Confirm unbound books and incomplete frames do not consume items; empty-hand right-click retrieves the book and clears the surface. Repeat with a Descriptive Book and with Immersive Portals installed: the latter should show the destination live. Restart with the portal active and confirm the surface/view returns; break the support crystal and confirm book/portal cleanup.
## 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 now defaults to `DISABLED` in new server configs while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs keep their explicit value. Issue #85 adds the active-book drop only to hand-held Linking and Descriptive Book travel. On a successful link, the exact active stack becomes a dedicated `DroppedBookEntity` at the departure point, rendered with the same open `BookModel` and textures as the book stand; a rejected link restores it to the original hand. Stand and portal use leave their books in place. The entity has unlimited vanilla lifetime and loses one of its five item-health points every 10,000 loaded ticks, plus one per wet tick, matching Legacy's slow/wet decay. Unlike Legacy's custom entity, the modern book is picked up by ordinary contact, not sneaking with an empty hand, and remains subject to ordinary item hazards. Following is not yet a Rivencraft modifier, so no Following exception is exposed or silently simulated. Both hands can activate a book, whereas Legacy's explicit stack removal tested only its selected main-hand slot.
## 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 has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, Linking Book, and Receptacle recipes. #79 keeps these data-driven and adds recipe-book unlocks for Crystal Block, Receptacle, and Ink Vial. The Legacy potion-based ink variant is omitted: in 1.21.1 a simple potion ingredient does not return its glass bottle, while the existing water-bucket recipe returns its bucket. The Legacy Folder/Portfolio use Rivencraft's Notebook; the old two-part Desk is one item; the old Lectern/Book Stand recipes use a vanilla Lectern converted in-world by adding a Rivencraft book or page. Blank Pages remain noncraftable and Link Panels remain outside the Desk. JEI automatically displays the JSON crafting recipes; its existing optional categories cover Mixer, Desk, and Binder processes. The lectern interaction intentionally has no JEI guide. With Create installed, an optional Ponder scene attached to the Receptacle item demonstrates frame construction and book insertion; the core mod still runs without Create. |
| 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.
+94
View File
@@ -0,0 +1,94 @@
# Live block-instability profiling (0.5.8.11–0.5.8.17)
Mystcraft Legacy measures the abundance of watched blocks in generated chunks,
compares that abundance with a baseline, and adds only positive excess to the
Age's instability. Its `InstabilityBlockManager` and `ChunkProfiler` are
available in the user's local 0.13.7.06 archive. Legacy's Age-side threshold
is more than 400 profiled chunks; its default tolerance is 1.05. The Legacy watched
blocks include coal, lapis, iron, emerald, redstone, gold and diamond ores,
quartz ore, glowstone, and crystal blocks. Diamond **blocks** are not on that
default list, but are included here at the player's request.
Rivencraft samples up to 1,024 distinct loaded chunks per dimension,
at most four per server tick. While a player is in an Age, it also gradually
generates unsampled chunks around that Age's spawn and the Overworld spawn,
at most one chunk per second across both dimensions, until their minimum
sample counts are reached.
The Overworld is sampled as the baseline; an Age needs 401 samples and the
baseline needs 500 before the live block component contributes points. Counts and sampled chunk
positions persist per dimension, preventing reloads from double-counting.
For each watched block, Rivencraft compares average blocks per sampled chunk
and charges `max(0, Age average − 1.05 × Overworld average) × weight`, rounded
to the nearest point. The block component is capped at 100,000 points.
Legacy weights are used for its watched blocks: coal/lapis 5, iron 60, emerald
200, redstone 250, gold 750, diamond 4,000, quartz/crystal 20, and glowstone
50. Deepslate variants inherit their ore's weight. Modern/high-value extensions
include diamond, emerald, gold, iron, raw gold, raw iron and netherite storage
blocks, plus ancient debris. Netherite blocks cost 8,000 per excess block per
sampled chunk; ancient debris costs 4,000.
Other mods remain optional. When an installed mod registers common-tagged ores
or storage blocks, recognizable `*_ore` blocks, or source-fluid blocks,
Rivencraft automatically includes those blocks in live profiling. The source
fluid weight is 1.25; common mineral names such as tin,
lead, zinc, silver, uranium, platinum, and iridium have explicit tiers, while
other tagged ores use 60 and other tagged storage blocks use 120. The optional
`instability_modded_materials.block_weight_overrides` list accepts a full-block
`modid:block_id=points` entry to cover a block without common tags or replace
its inferred weight; zero excludes it. Block entities and non-source fluid
plants are excluded. Rivencraft never requires any particular content mod.
Profiles written before 0.5.8.17 did not retain all modded material
counts, so their block sample data resets and is collected again after upgrade.
The saved Age definition and authored score are not changed. Later config
changes can rescore materials already captured in the profile; a newly added
override for a block that was not previously recognized needs fresh samples.
The authored score and provider plan remain saved as the Age's immutable base.
Only Ages created with instability schema v2 participate; existing v1 Ages
retain their previously frozen scores/plans, including neutral pre-scoring
Ages. This avoids silently rescoring existing worlds.
The live block component creates a deterministic **effective** score and
provider plan from that base plus the persisted profile; new cards can become
active as sampled abundance rises. The command `/rivencraft age instability`
shows both the saved base and the currently profiled block component. An
unloaded Age has no active profile view until its dimension is loaded again.
Turning an effect off does not delete counts or reroll the same effective plan.
This is not byte-for-byte Legacy profiling. Legacy has a spatial accessibility
map and two factors per block, actively builds a dummy-world baseline, and can
recompute after subsequent chunk profiling. Rivencraft uses a simpler
per-chunk abundance metric, a real-world Overworld baseline, a sample/work cap,
and a saved authored base so modern servers remain bounded and restart-safe.
The greater-than-400-chunk activation delay is retained. Already-explored
chunks count when reloaded, but the bounded spawn-area pass can generate
additional terrain. A mod that mutates blocks after the chunk-load event may
not be reflected in the first sample.
## Manual check handed to the player
The first focused GameTest run after adding the chunk-load listener stalled in
its initial 50-test batch and was stopped. It is **not** a passing test result.
Do not retry that stalled scenario automatically. In a disposable world:
1. Create one Age with an authored Diamond Block + Rocks (or Floating Islands)
combination and a comparison Age using Stone for the same feature. Explore
enough distinct chunks in each Age for `/rivencraft age instability` to show
a profiled-block contribution. Explore the Overworld too if its baseline has
not reached 400 chunks. The diamond Age should score higher and can gain
provider cards; the stone Age should not get a diamond-block contribution.
2. Leave and restart the world. Re-enter both Ages and compare their scores and
provider IDs/levels. Reloading old chunks must not increase the sample
count or score by itself. Generating more diamond-rich chunks may change the
effective score and provider set; disabling instability must suppress their
effects without erasing the profile.
3. Check a naturally generated diamond-ore Age without authored diamond-block
features. If its ore abundance is near the Overworld baseline, it should
receive little or no ore contribution. Check that profiling does not make
chunk loading noticeably stall, and report any exception or server hang.
Do not use a valued save for this check. The player reported an in-game pass
for the 0.5.8.18 candidate on 2026-10-12. The three detailed scenarios above
remain useful for future regression runs; the report does not establish that
each was exercised individually.
+66
View File
@@ -0,0 +1,66 @@
# Milestones at a glance
This is the condensed milestone history. The [current implementation](CURRENT_IMPLEMENTATION.md)
is authoritative for what the latest development build does; older acceptance
and audit files remain available as dated evidence, not competing current
instructions. [Gitea milestones](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestones)
hold the live issue status.
## 1 — Bootstrap (complete)
Established the NeoForge 1.21.1/Java 21 project, mod identity, registries,
assets, build, and first automated checks. The port deliberately shares
Mystcraft's concepts while maintaining its own modern package and data model.
See [porting principles](PORTING.md) and [asset provenance](ASSET_PROVENANCE.md).
## 2 — Books and linking (complete)
Introduced persistent Linking and Descriptive Books, conversion of vanilla
Lecterns into Book Stands, named books and Ages, safe travel, destination
previews, and a configurable Return Link policy. Later milestones refined
renderer selection and portal travel. See [preview compatibility](PREVIEW_COMPATIBILITY.md).
## 3 — Ages (complete)
Added persistent runtime dimensions, deterministic random Age traits,
name-derived unique dimension folders, terrain/biome choices, spawn recovery,
and Age lifecycle commands. See [Age administration](AGES.md),
[runtime profiles](AGE_PROFILES.md), and [random weights](RANDOM_AGE_WEIGHTS.md).
## 4 — Writing system (complete)
Introduced Symbol Pages, Link Panels, Notebooks, the Writing Desk, Book Binder,
and Ink Mixer. Writing order and unknown symbol references persist instead of
being inferred from UI text. See [writing data](WRITING_DATA.md),
[descriptive-book presentation](DESCRIPTIVE_BOOK_PRESENTATION.md), and
[Ink Mixer audit](INK_MIXER_MODIFIER_AUDIT.md).
## 5 — Age grammar (complete for the supported subset)
Compiled supported pages into frozen Age plans: terrain, biome distribution,
environment, features, block/fluid materials, structures, crystals, and
fissures. Added optional TerraBlender and Serene Seasons adapters. This was
not a claim of complete Legacy grammar parity. See [M5 acceptance](MILESTONE_5_ACCEPTANCE.md),
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md),
[symbol schema](SYMBOL_SEMANTICS.md), [TerraBlender](TERRABLENDER_COMPATIBILITY.md),
and [Serene Seasons](SERENE_SEASONS_COMPATIBILITY.md).
## 6 — Instability (complete)
Added authored and live block-abundance scoring, frozen provider plans,
operator diagnostics, bounded status effects, decay, lightning, explosions,
meteors, burning, and erosion. Safety budgets are deliberate modern
adaptations. See [M6 acceptance](MILESTONE_6_ACCEPTANCE.md),
[Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md), and
[runtime policy](INSTABILITY_RUNTIME.md).
## 7 — Structures and polish (complete in 1.0.0.2)
Added Legacy-layout Age libraries, page-bearing stands, crystal portals and
receptacles, the handheld dropped-book entity, data-driven recipes,
advancements, asset/audio polish, optional Ruins, compatibility updates, and
expanded sky/glyph pages. Issue #83 covered regression, migration, and document
consolidation; issue #84 records the final milestone acceptance. See the
[M7 acceptance](MILESTONE_7_ACCEPTANCE.md), [M7 Legacy audit](LEGACY_M7_AUDIT.md),
[M7 regression matrix](MILESTONE_7_REGRESSION.md), and
[M7 compatibility review](MILESTONE_7_COMPATIBILITY_REVIEW.md).
+39
View File
@@ -0,0 +1,39 @@
# Milestone 5 acceptance: Age grammar
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
- Schema-v2 symbol JSON lives at `data/<namespace>/rivencraft/symbols/<path>.json`. Its file path is the stable page ID; `name`, `description`, and `category` are display metadata, while typed `semantics.role` and `semantics.value` control generation on the server. See [schema and role table](SYMBOL_SEMANTICS.md). Schema v1 remains display-only. Registry-derived biome, solid-block, and source-fluid pages are generated from valid registered entries rather than requiring one PNG or JSON definition per material.
- Ordered writing is parsed as Link Panel, terrain, biomes/controller, weather, season, lighting, sky, celestial modifiers/celestial, then material/feature pairs. Missing branches are seeded completion warnings. Unresolved symbols, unsupported combinations, conflicting single-choice pages, incorrect order, and an invalid Link Panel produce Binder diagnostics before items are consumed. Repeated feature and material pages retain their occurrence order. See [grammar and diagnostics](WRITING_GRAMMAR.md).
- Legacy's grammar ranks informed the random-choice weights; Rivencraft does not claim to run Legacy's complete 1.12.2 grammar or reproduce its terrain byte-for-byte. The [source audit](LEGACY_AGE_GRAMMAR_AUDIT.md), [weight table](RANDOM_AGE_WEIGHTS.md), and [runtime profiles](AGE_PROFILES.md) record exact mappings and adaptations. Authored supported symbols override seeded choices. A page-free or Link-Panel-only Descriptive Book creates a random Age. The resolved plan, seed, profile, biome IDs, traits, features, and writing order are saved with its Age identity before chunks generate. Reopening a book or restarting must not reroll it.
- Feature pages cover decorations, terrain additions, dense ores, crystals, fissures, and supported structures including Trial Chambers and newer vanilla structures. Material pages may explicitly choose a compatible block/fluid or receive a seeded compatible feature. Terrain and biome eligibility still restrict combinations; incompatibility is reported rather than silently ignored. [Age documentation](AGES.md) and [feature rules](WRITING_GRAMMAR.md) describe the supported subset.
## Optional integrations and fallback
| Integration | Milestone 5 result |
| --- | --- |
| TerraBlender | Optional adapter reads registered Overworld and Nether region biome IDs. Any compatible biome-producing mod using those regions can supply eligible pages; no Biomes O' Plenty ID is hard-coded. Missing TerraBlender leaves vanilla Age generation intact. A removed biome warns and uses an eligible load-time fallback without rewriting the saved plan. See [TerraBlender checks](TERRABLENDER_COMPATIBILITY.md). |
| Serene Seasons | Optional per-Age seasonal modes: No Seasons, Seasons, Eternal Winter/Summer/Fall/Spring. The adapter enrolls dynamic Age IDs in Serene's whitelist and synchronizes connected clients without a restart. Its internal config/saved-clock hooks are version-sensitive because a public dynamic-dimension registration/fixed-season API is unavailable. Without Serene, only No Seasons is valid for a *new* book; saved Ages retain their data with effects inactive. See [config and real-client checks](SERENE_SEASONS_COMPATIBILITY.md). |
| Ecliptic Seasons | Deliberately not integrated. Its 1.21.1 `ValidDimensions` setting requires a world restart and caches validity on server/client level load; no supported live registration or per-dimension calendar-control API was found. Issue #39 records the blocker and the upstream API needed to revisit it. Rivencraft does not require Ecliptic and does not support both season mods together. |
| JEI, Create/Ponder, Immersive Portals | Optional presentation/integration paths; none is required for core dedicated-server loading or Age grammar. Existing preview fallback and manual checks remain in [preview compatibility](PREVIEW_COMPATIBILITY.md). |
## Migration, limits, and milestone boundary
Existing saved writing keeps its stable IDs and order. Unresolved pages remain recoverable in books instead of being silently replaced. Existing bound books without a plan are not reinterpreted as newly authored grammar. A frozen plan can load without the old symbol definition or runtime-profile JSON, but missing referenced registry resources prevent or alter that load as documented in [runtime profiles](AGE_PROFILES.md); restore the data pack/mod and keep a backup. The migration regression suite covers legacy writing components, schema-v1 display pages, missing symbols, and old Binder/Desk data. It is **not** a promise of full compatibility with every pre-release world; that broader requirement is tracked separately by open issue #60.
Milestone 5 owns recognized symbol semantics, ordered grammar, deterministic completion, persistent Age configuration, and supported feature/integration effects. Milestone 6 owns instability from invalid or excessive writing and server-safe instability effects. Milestone 7 owns remaining structures/polish, portals, recipes, models, sounds, advancements, and migration depth. Several Legacy structures and crystals were pulled forward into Milestone 5 as their own closed port issues; that does not imply the rest of Milestone 7 is complete. Custom multi-body skies, gradients, unsupported direct Rain/Snow pages, and exact Legacy terrain/feature parity remain outside the accepted M5 subset. Open issue #44 tracks custom skybox work.
## Test and acceptance matrix
| Check | Result or handoff |
| --- | --- |
| Default dedicated-server GameTests, no optional mods | 26/26 passed after moving 25 detailed generation checks to the selectable `generation` archive. Core persistence, grammar, transaction, and migration checks remain. |
| Focused generation archive | 51/51 passed on the reduced core plus `-ParchivedGameTestSuites=generation`. No generation test was deleted; 25 detailed checks moved out of the default run. |
| Full archive | Previously passed 116/116 before the final random-biome-count adjustment in #41. Its later rerun stalled in the first batch and was stopped under the project GameTest rule; **not** claimed as a passing current run. Manual random-Age biome and six-terrain travel checks are in [GameTest handoffs](GAMETESTS.md). |
| Optional TerraBlender | An earlier optional-mod GameTest run passed 51/51; Biomes O' Plenty/Nether-region behavior was checked in-game during #37. The published jar does not bundle TerraBlender. |
| Optional Serene Seasons | In-game acceptance was recorded on #38 for six pages, saved whitelist, and no-restart sync. The GameTest mock player cannot negotiate Serene's season packet; the real-client path is documented. |
| Gradle build and JAR | `./gradlew build --offline --no-daemon` passed and produced `build/libs/rivencraft-0.5.0.0.jar`. Its SHA-256 is attached to the Gitea release. |
| Asset audit | Three byte-identical PNGs were replaced by references to their retained counterparts; no other unreferenced shipped textures or sounds were found. The user-provided `dev images` reference remains untouched. |
Completion checklist: implementation issues reviewed; #39 explicitly scoped as unsupported; schema/Legacy/grammar/generation/integration/migration boundaries documented; core and focused validation recorded; current archive stall disclosed; release JAR and SHA-256 published; issue #42 and Milestone 5 closed **only after** those release artifacts are verified.
+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.
+158
View File
@@ -0,0 +1,158 @@
# Milestone 6 instability regression and migration check (#73)
This is the release-slice acceptance record for instability, not a promise of
general old-save compatibility. The latter remains issue #60. Run the focused
archive with `./gradlew build runGameTestServer -ParchivedGameTestSuites=instability`;
it includes the default smoke suite.
For 0.5.8.10 on 2026-10-10, that dedicated GameTest server passed all 55 required tests
with no optional content/client integrations installed (the required Dynamic
Dimensions and Bad Packets dependencies were present). The full archive was not run;
it has a previously documented storage stall, so do not count it as passing.
The 0.5.8.11 run with live block profiling stalled in its initial batch and
was stopped; it is **not** a passing result. The live profiling scenario is
handed to the player in [the block-profile checklist](LIVE_BLOCK_INSTABILITY.md).
## Automated coverage
| Requirement | Existing default test or focused archive |
| --- | --- |
| Stable writing, defective writing, score explanations, repeated use of the same book | `AgePersistenceGameTests.instabilitySeparatesStableAndDefectiveWriting` |
| Frozen score, age-scoped writing cost, random baseline | `AgePersistenceGameTests.randomBaselineAndBrightBalanceAreFrozen`, `auditedSymbolCostsAreCountedAndAgeScoped` |
| Complete Age definition, score and provider-plan save/load | `AgePersistenceGameTests.authoredPlanRetainsResolvedMeaningAndAgeIdentity`; `AgeInstabilityRuntimeGameTests.selectionIsDeterministicAndFrozen` |
| Old pre-scoring Ages, score-only saves, removed symbols, unavailable handlers | `AgePersistenceGameTests.missingRegistryEntryDoesNotReinterpretAnOldAge`, `instabilitySeparatesStableAndDefectiveWriting`; `AgeInstabilityRuntimeGameTests.savedPlanSurvivesAgeUpdatesAndMissingHandlers` |
| Per-Age controls and changed config without score/plan reroll | `AgeInstabilityRuntimeGameTests.perAgeControlsPersistWithoutChangingScoresOrOtherAges`; `Milestone6RegressionGameTests.disablingEffectsCannotRerollFrozenPlan` |
| Hazard cooldown and attempt sequence across saved-state reload | `AgeInstabilityRuntimeGameTests.hazardCooldownAndAttemptSequenceSurviveReload` |
| Active-Age rotation, action/tick budget | `AgeInstabilityRuntimeGameTests.ageScopeRotationAndActionBudgetAreBounded` |
| Multiplayer loaded-chunk-only candidate selection, deduplication and cap | `Milestone6RegressionGameTests.multiplayerCandidatesStayLoadedDeduplicatedAndCapped` |
| Decay loaded-chunk target and spawn protection | `AgeDecayGameTests.targetMustRemainInChosenLoadedChunk`, `savedSpawnAndPlatformAreProtected` |
| Explosion policy gates and protected blocks | `Milestone6RegressionGameTests.disabledGriefAndProtectedBlocksStayUntouched` |
| No Overworld status or decay provider | `AgeStatusEffectGameTests.overworldCannotRunAnAgeStatusProvider`; `AgeDecayGameTests.overworldCannotRunDecayProvider` |
| Optional-mod material weights, block overrides, and old profile migration | `Milestone6RegressionGameTests.commonModdedMaterialWeightsStayBounded`, `blockProfileSamplesPersistWithoutDoubleCounting` |
| Ordered celestial modifier consumption, repeated modifiers, and dangling +100 per kind | `WritingGrammarGameTests.orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences`; `EnvironmentWritingGameTests.celestialModifiersFollowWrittenOrder` |
These tests exercise saved-data serialization and deterministic policies. They
do **not** simulate a complete process restart, an actual multiplayer server,
optional-mod installation, or real terrain and hazard visuals. The three new
checks live only in the `instability` archive to avoid duplicating default tests.
## Migration outcome and known gaps
- Pre-scoring Milestone 5 Ages load with a neutral score and no effects; they
are not retroactively rescored under current writing rules.
- Score-only Ages receive a deterministic provider plan once from their saved
score and Age seed. An already-saved score and plan retain their meaning when
pages, handlers, or runtime config change. Unknown but syntactically valid
provider IDs are preserved and skipped if their handler is unavailable.
- Missing symbol definitions do not reinterpret a frozen instability score.
That check is narrower than arbitrary missing biome, block, or worldgen
registry entries: loading a world with removed data packs is not guaranteed.
Broad save compatibility and migration remain tracked in #60.
- Version-3 plans keep their previous stage-sorted celestial interpretation and
frozen writing cost. Only new version-4 plans consume period/phase modifiers
in authored order and charge a trailing modifier once per pending kind.
- In-process roundtrips cannot prove that Age dimensions reload correctly
after a full stop/start. Check that in a real client/server world. Do not add
another GameTest for it if it hits the documented storage stall; hand the
exact scenario to the player instead.
- The dedicated GameTest run establishes classloading with no optional runtime
flags. It does not establish classloading with every optional integration
installed, nor the reverse of removing one from an existing save.
## Manual in-game matrix
Use a fresh test world, then keep one save for the restart and data-pack checks.
Record the seed, mod list, age name, and `/rivencraft age instability` output
before and after each change. Do not deliberately remove a required registry
entry from a valued world; copy the test save first.
1. Bind a low-instability book and a deliberately unstable book. Reuse each
book and verify the Age name, dimension ID, score, selected providers and
provider levels remain unchanged. Exit the Ages, stop Minecraft completely,
restart and re-enter; compare the same diagnostic values.
2. With two survival players in one unstable Age and a third player in the
Overworld, verify effects stay in the Age, follow the Age players, and do
not appear in the Overworld. Move both Age players away, let old chunks
unload, and confirm hazards do not keep remote chunks loaded or operate
there. Confirm controls on one Age do not alter a second Age.
3. Disable global instability, then world mutation, then one provider. Verify
the suppressed effect stops and the frozen score/plan do not change after
re-enabling or restarting. With block destruction disabled, trigger an
explosion in an expendable Age; check that arrival platforms, portals,
block entities and protected blocks survive. Check lightning, explosion,
decay, status-effect, burning, meteor and erosion visuals where selected.
4. With Biomes O' Plenty or another TerraBlender biome mod, confirm recognized
biome pages still author an Age and the instability diagnostics remain
stable after restart. Repeat with Serene Seasons installed, then absent in
a disposable copy; confirm its Age season behavior and that Rivencraft
itself still loads. JEI should display the relevant pages and recipes when
installed, while the mod works without it.
Separately, install a mod with common-tagged ores/storage blocks or source
fluids. Compare a material-rich Age with an ordinary Age after the profile
thresholds are reached; the installed mod's recognized blocks should affect
the result without a Rivencraft allowlist. Then remove that optional mod in
a disposable copy and confirm Rivencraft still starts. Test a single
`block_weight_overrides` entry and a zero-weight exclusion. This remains a
player-run scenario, not an automated pass.
5. Check the book preview separately with its fallback animation, Ponder/Create,
and Immersive Portals when available. Opening a book should not change
player location, instability diagnostics, or ambient audio in the actual
Age. Do not install both season mods together for this matrix.
6. On a copied save, remove or alter a **nonessential** data pack providing
a writing symbol or optional handler, then reload. Existing saved scores
and provider plans must not reroll. If a referenced worldgen registry entry
prevents load, record it against issue #60 rather than treating #73's
frozen-instability guarantee as general missing-registry support.
The matrix is guidance for a player-run pass; none of these six scenarios is
claimed as completed by the automated result above.
The player reported an in-game pass for the 0.5.8.18 candidate on 2026-10-12.
That is a release-slice acceptance report, not a claim that every expanded
matrix row below was individually exercised.
### Regression run record
For each row, record pass/fail, world seed, Age name, Rivencraft version,
optional-mod versions, and a short observation. "Not run" is intentional
until a player completes the in-game check; a successful build is not a pass.
| Area | Specific manual observation | Status |
| --- | --- | --- |
| New vs reused book | Dimension ID, score, and providers do not reroll | Not run |
| Full process restart | Re-enter two Ages; saved traits, score, plan, and cooldowns persist | Not run |
| Multiplayer isolation | Two Age players receive effects; Overworld player does not | Not run |
| Loaded-chunk scope | Departing players permit unloading; hazards do not reach old chunks | Not run |
| Controls | Global, per-Age, mutation, and provider toggles suppress effects without reroll | Not run |
| Protected blocks | Explosion/decay/erosion spare arrival platforms, portals, and block entities | Not run |
| Hazard visuals | Lightning, explosions, decay, status, burning, meteor, and erosion each behave when selected | Not run |
| Third-party biomes | TerraBlender biome pages and cave classification survive Age creation/restart | Not run |
| Optional integrations absent | Mod launches without JEI, Create/Ponder, Immersive Portals, Serene Seasons, or biome mods | Not run |
| Optional integrations present | JEI pages/recipes, preview backends, seasonal behavior, and ambient sound work | Not run |
| Optional-mod materials | Tagged ore/storage/source-fluid samples change live profile automatically | Not run |
| Material overrides | Positive and zero `block_weight_overrides` values affect only named blocks | Not run |
| Removed optional mod | Disposable copy launches without the mod; unknown sampled IDs stay serialized | Not run |
| Removed symbol/handler | Frozen score and provider plan survive when nonessential data is removed | Not run |
| Ordered modifiers | Five book-order cases below produce expected time and dangling costs | Not run |
### Focused ordered-writing and optional-material checks for 0.5.8.18
Use `/rivencraft age instability` in each newly bound Age; old Ages retain
their original plan and score. Record each row as pass/fail with the Age name,
world seed, installed optional mods, and the command output. The ordered
modifier assertions are archived, not a claim that the stalled GameTest suite ran.
| Book pages after Link Panel, in exact order | Expected result | Result |
| --- | --- | --- |
| Half Length, Double Length, Normal Time | Length combines to 1.25×, no dangling cost | Not run in game |
| Normal Time, Zero Length | Sun keeps normal motion; one dangling +100 | Not run in game |
| Rising, Normal Time, Setting | Rising start phase; Setting dangles +100 | Not run in game |
| Half Length, Double Length, no celestial page | One combined pending length dangles +100; seeded clock remains | Not run in game |
| Normal Time, Zero Length, Normal Time | Last sun consumes Zero Length; duplicate celestial cost remains separate | Not run in game |
With a common-tagged ore/storage-block or source-fluid mod installed, bind
comparable material-rich and ordinary Ages and wait for profiling thresholds.
Verify the modded material contributes automatically, then restart and confirm
the score/profile remains consistent. On a disposable copy, remove the mod and
confirm Rivencraft launches without it; restore the mod before judging the
world's registry-dependent terrain. Also check a zero-weight override and a
positive override. These are optional-mod compatibility checks, not a new
required dependency.
+72
View File
@@ -0,0 +1,72 @@
# Milestone 7 acceptance — 1.0.0.2
Milestone 7 is complete for the supported NeoForge 1.21.1 feature set. This is
not a claim of total Mystcraft Legacy parity or exhaustive optional-mod
certification. The [current implementation](CURRENT_IMPLEMENTATION.md) is the
up-to-date behavior summary; the [regression matrix](MILESTONE_7_REGRESSION.md)
separates automated evidence from manual scenarios.
## Scope and issue gate
The Gitea audit for issue #84 found no other open issues. Required existing
#14 (cave terrain), #43 (glyph numbers), #44 (sky), and #60 (old-save policy)
were closed. M7 implementation and review issues #75–#83, #85–#90, and
later additions #91–#94 were closed before this acceptance. No required
issue was silently deferred.
| Legacy concept | Rivencraft implementation or deliberate difference |
| --- | --- |
| Cave terrain and writing | Cave terrain uses its cave/Nether biome set and a modern Y=64-start spawn search. Existing non-cave terrain still supports caves as features. See [Age profiles](AGE_PROFILES.md) and [grammar](WRITING_GRAMMAR.md). |
| Libraries | The recognizable Legacy small-library layout and sparse candidate spacing are ported as a structure template with a chest and up to five page-bearing Book Stands. It generates only in Ages and only on suitable surfaces; player-selectable Legacy or Age-only loot is supported. See [Legacy M7 audit](LEGACY_M7_AUDIT.md). |
| Crystal portal and Receptacle | Connected Crystal Blocks support vertical and horizontal portals. The held book stays in the Receptacle; removing it removes the surface. Rivencraft validates loaded bounds and block protection, saves owner pointers, and uses book-travel rules. Optional Immersive Portals supplies a live destination view, with an animated fallback while unavailable. |
| Handheld Linking/Descriptive Books | The used book remains behind as a rendered book entity at the origin. The exact Legacy entity pickup rule is not copied: modern contact pickup is used. Return Links default to disabled but remain configurable. |
| Recipes and discovery | JSON crafting recipes and recipe-book unlocks cover the supported writing/crystal progression. Optional JEI covers the Binder, Writing Desk, and Ink Mixer; a Lectern conversion has no JEI guide. Blank Pages are not craftable. Optional Create/Ponder teaches portal construction. See [JEI review](MILESTONE_7_JEI_REVIEW.md). |
| Models, sounds, and advancements | Legacy-sourced assets are credited in [asset provenance](ASSET_PROVENANCE.md); 1.21.1-compatible models and renderer adaptations are documented in the [visual audit](MILESTONE_7_VISUAL_AUDIT.md). Server-owned advancements cover book, writing, Age, and portal progress; the unimplemented Quinn equivalent is explicitly omitted in [advancement notes](MILESTONE_7_ADVANCEMENTS.md). |
| Numbers, sky, and extra items | Base-25 number glyphs, celestial modifiers and eclipses, Symbol Portfolios, the OP-only Link Modifier, and optional Ruins pages were follow-up additions. Legacy page semantics are adapted to the modern authoring plan rather than a binary copy of 1.12.2 worldgen. |
## Compatibility and migration
NeoForge **21.1.264** is the minimum 21.1 build. A player passed the
singleplayer client with the optional stack, then a local dedicated server
including Immersive Portals, before the minimum was raised. See the
[compatibility decision](MILESTONE_7_COMPATIBILITY_REVIEW.md). JEI
19.69.0.457 and Serene Seasons 10.1.0.9 received their own player checks;
Create/Ponder, Immersive Portals, TerraBlender/biome add-ons, Serene Seasons,
JEI, and Ruins remain optional. Ecliptic Seasons is deliberately not
integrated: its 1.21.1 public API does not support the required live Age
registration and per-Age calendar control. Missing optional mods must not
prevent the core mod from loading.
Known older writing/book, Age-plan, and workstation formats have explicit
migration or stable-default handling. An Age registry and its dimension
folders must be backed up together. Removed mods, missing custom registry
objects, and arbitrary prerelease saves are **not** guaranteed to load.
Retiring an Age through the creative Link Modifier archives its data and
invalidates its UUID; that intentional destructive operation is not a
migration. See the [old-save boundary](MILESTONE_7_REGRESSION.md#old-save-boundary-60).
## Release validation and limits
The 0.6.11.0 regression candidate passed `./gradlew --offline build` and
fresh-world GameTest runs: core **91/91**, generation **129/129**, and
environment **103/103**. The player reported an in-game pass for that
candidate. The 1.0.0.1 release candidate additionally allows Number Pages to
set Celestial Size. The 1.0.0.2 release makes Binder status diagnostics an
OP-only, default-off world setting. Its offline build and core GameTests
passed (93/93), and the player reported an in-game pass. The
preceding 1.0.0.0 candidate passed core and generation GameTests. Its
environment archive stalled in the first 50-test batch, repeatedly printing
its field without completing; it was stopped and is **not** counted as a
1.0.0.0–1.0.0.2 pass. The previous 0.6.11.0 run of that same suite passed 103/103.
The focused sky, season, and number-page in-game checks in the regression
matrix remain useful regression checks, not claimed full-matrix passes. The
Gitea release changelog records the tested JAR and verified SHA-256.
These results do not prove every two-player race, full restart, migration
from every development world, or optional-mod combination. The specific
manual matrix remains in [M7 regression](MILESTONE_7_REGRESSION.md); those
broader combinations are known validation limits, not claimed passes. Modern
chunk and tick budgets, safer portal validation, 1.21.1 world-height and
biome APIs, and the dynamic-dimension backend are deliberate deviations from
Legacy. A complete Legacy worldgen or link-property port is outside this
milestone.
+40
View File
@@ -0,0 +1,40 @@
# Milestone 7 advancements and discovery (#81)
Rivencraft's advancement tab follows the visible progression in Mystcraft
Legacy's `assets/mystcraft/advancements/`: Page, unwritten Linking Book,
Writing Desk writing, Age Book, and safe Age travel. Titles such as *The Ending
Has Not Yet Been Written*, *D'ni Ink*, and *The Art of Writing* reference Myst
without requiring any assets from that game. The Legacy hidden Quinn trigger
has no established Rivencraft action and is deliberately omitted.
`root`, `page`, and `linking_book` use vanilla server-side tick/inventory
criteria. `link_panel`, `copy_page`, `bind_age`, `open_portal`, and `enter_age`
use an impossible criterion awarded only after the server commits the
corresponding action. The Age-travel award requires a successful book or portal
link ending in a registered Rivencraft Age. Opening a book GUI, rendering a
destination preview, viewing a recipe, or merely loading an Age cannot award
it. Progress is stored by Minecraft per player and survives world restarts.
Vanilla recipe-book unlock advancements now cover the Writing Desk, Ink Mixer,
Book Binder, and Notebook, in addition to the existing Ink Vial, Crystal
Block, and Book Receptacle unlocks. The component-sensitive Linking Book
crafting recipe remains a special recipe: its Link Panel requirement and the
Writing Desk/Ink Mixer/Book Binder processes are explained by Rivencraft's
optional JEI integration. No recipe or advancement requires JEI, Create, or
Immersive Portals to be installed.
Manual Survival check: collect a Page and Linking Book; use the Ink Mixer to
take a Link Panel; copy a Page at the Writing Desk; bind a Descriptive Book;
enter its Age; activate a Crystal Portal. Confirm each advancement appears
only after the completed action, not from an output preview or GUI opening.
Repeat the actions with two players to confirm only the actor receives each
award. Restart an existing save and confirm completed progress remains, then
complete one previously unearned milestone. With JEI absent, the vanilla
recipe book should still reveal standard crafting recipes as their ingredients
are discovered; with JEI installed, the special writing guides should appear.
The automated Book Binder output-award scenario stalls in the GameTest storage
runner, so it is intentionally omitted under the project test policy. For its
manual check, open a ready Binder, observe that merely previewing the finished
book does not award *A Book of Ages*, then take the book and confirm the award.
Repeat by shift-clicking the result in a fresh Binder.
+53
View File
@@ -0,0 +1,53 @@
# Milestone 7 compatibility review (#82)
Reviewed 2026-10-19 for Minecraft 1.21.1. The project now compiles against
NeoForge 21.1.264 and advertises that as its minimum. The official
[NeoForge Maven metadata](https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml)
lists 21.1.264 as the newest 21.1 release. Its
[changelog](https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.264/neoforge-21.1.264-changelog.txt)
includes received split-packet size checks (21.1.260), network setup payload
direction/decoding limits (21.1.261), ad-hoc channel identifier limits
(21.1.262), and an initial attachment-sync fix (21.1.264). Those are concrete
reasons to evaluate an upgrade, but do not by themselves prove Rivencraft and
its optional integrations work on that loader.
**Decision: adopt 21.1.264 beginning with 0.6.6.2.** An override build
succeeded, but its GameTest server repeated its storage-progress display
without producing test results. That run was stopped; it is neither a pass nor
evidence of a NeoForge defect. The player subsequently passed an in-game
singleplayer test on 21.1.264 with the optional stack, then passed a dedicated
server test including Immersive Portals travel to a Rivencraft Age. The
dedicated server also reached the ready state with only the required mods
before the full optional stack was installed. Both modes used Rivencraft
0.6.6.1. The earlier 21.1.251 baseline passed 70 required GameTests for
release V0.6.6.0.
| Dependency | Project baseline | Newest checked 1.21.1 release | Follow-up |
| --- | --- | --- | --- |
| Dynamic Dimensions AR Edition (required) | 0.9.1 | 0.9.1 | No newer release found on [CurseForge](https://www.curseforge.com/minecraft/mc-mods/dynamicdimension-ar-edition/files/all). |
| Bad Packets (required) | neo-0.8.2 | neo-0.8.2 | No newer matching [Modrinth](https://modrinth.com/mod/badpackets/versions) release found. |
| JEI (optional) | 19.57.0.449 at #82 review | 19.69.0.457 beta | Updated in #86 after client and dedicated GameTest passes; see [JEI review](MILESTONE_7_JEI_REVIEW.md). |
| Create (optional) | 6.0.10 | 6.0.10 | No newer matching [Modrinth](https://modrinth.com/mod/create/versions) release found. Its jar bundles Ponder 1.0.82. |
| Ponder (bundled with Create) | 1.0.82 | 1.0.87 library artifact | [Maven](https://maven.createmod.net/net/createmod/ponder/ponder-neoforge/maven-metadata.xml) has a newer artifact, but Create 6.0.10 still bundles 1.0.82. Do not treat this as a separately installed Create update. |
| Immersive Portals (optional) | 6.0.7 | 6.0.7 | No newer matching [Modrinth](https://modrinth.com/mod/immersive-portals-neoforge/versions) release found. |
| TerraBlender (optional) | 4.1.0.8 | 4.1.0.8 | No newer matching [Modrinth](https://modrinth.com/mod/terrablender/versions) release found. |
| Serene Seasons (optional) | 10.1.0.8 at #82 review | 10.1.0.9 | Updated in #88 after the player's full seasonal matrix passed; see [Serene review](MILESTONE_7_SERENE_REVIEW.md). |
| GlitchCore (Serene dependency) | 2.1.0.2 | 2.1.0.2 | No newer matching [Modrinth](https://modrinth.com/mod/glitchcore/versions) release found. |
| Biomes O' Plenty (TerraBlender test addon) | 21.1.0.14 in user's Prism instance | 21.1.0.14 | No newer matching [Modrinth](https://modrinth.com/mod/biomes-o-plenty/versions) release found. |
The Modrinth version comparisons above were filtered to **NeoForge and
Minecraft 1.21.1** on 2026-10-19. Create's bundled Ponder version was read
from `META-INF/jarjar/metadata.json` in `create-1.21.1-6.0.10.jar`; the
separate library-artifact comparison came from Create's Maven. Dynamic
Dimensions was checked on CurseForge. Versions for other Minecraft lines and
similarly named mods were excluded.
The full dedicated test stack contained Create/Ponder, Immersive Portals, JEI,
TerraBlender with Biomes O' Plenty, and Serene Seasons with GlitchCore. The
server loaded a new `rivencraft:age/test`, and its shutdown log confirms that
all Overworld, Nether, End, and Age chunks were saved. The player explicitly
approved 21.1.264 after that test. This does not independently certify every
optional combination, the LEGACY preview fallback on a client without Create
and Immersive Portals, or a full restart/rejoin cycle; keep those in the
regression checklist. Serene's GameTest mock-player network limitation still
requires real-client checks for seasonal behavior.
+41
View File
@@ -0,0 +1,41 @@
# JEI compatibility review (#86)
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
now targets JEI 19.69.0.457 after the client's in-game acceptance check. JEI
remains a `compileOnly` API and an opt-in
`localRuntime` dependency for development; the published mod has no required
JEI dependency.
The [official 1.21.1 NeoForge files](https://www.curseforge.com/minecraft/mc-mods/jei/files/all?page=1&pageSize=20&version=1.21.1)
list 19.69.0.457 (beta, 2026-10-20) after 19.64.0.455 (release, 2026-10-16).
The [19.69.0.457 Modrinth changelog](https://modrinth.com/mod/jei/versions?g=1.21.1&l=neoforge)
has a published-jar bytecode compatibility check. Its preceding 19.69.0.456
build changes ingredient search to Lucene and adds a runtime ingredient-search
refresh API. This makes page search a required client check, even though the
current Rivencraft plugin still compiles against the 457 API. The new Maven
runtime artifact is much larger (about 7 MB versus 2.3 MB for 455); a successful
compile alone is not evidence that its runtime plugin works.
Checks performed before changing the pinned dependency:
| Configuration | Result |
| --- | --- |
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true build` | Passed; existing JEI API uses compile with four deprecation warnings and no errors. |
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true runGameTestServer` | Passed; dedicated GameTest server loaded JEI 19.69.0.457 and all 74 required tests passed. |
| `./gradlew -Pjei_version=19.69.0.457 runGameTestServer` | Passed without JEI; all 74 required tests passed. |
| Prism client with JEI 19.69.0.457 and Rivencraft 0.6.6.6 | Client joined an integrated world; JEI registered Rivencraft extra ingredients and started without a JEI-plugin exception. The player reported an in-game pass for guide display, page search, and recipe visibility. |
On first 457 launch, JEI migrated its old `lowMemorySlowSearchEnabled` config
key and saved a backup of the original `jei-client.ini`; that warning was
handled by JEI and did not block startup. No Rivencraft config was changed.
The in-game acceptance report covers the visible JEI integration. A dedicated
GameTest server proves no server-side startup failure but does not exercise the
JEI GUI; keep page search, recipes, blank-Page hiding, and the Ink Mixer,
Writing Desk, and Book Binder guides in future upgrade checks. JEI is not
bundled into or required by the published Rivencraft jar.
The final pinned `0.6.6.7` build passed. A repeat GameTest run from the pinned
configuration stalled during test-world preparation before running any cases;
it was stopped. The two successful pre-pin runs above used the same JEI version
and code, with the dependency supplied by a Gradle property override.
+79
View File
@@ -0,0 +1,79 @@
# Milestone 7 regression and migration matrix (issue #83)
This is the dated regression record for the 0.6.11.0 candidate. The
[Milestone 7 acceptance](MILESTONE_7_ACCEPTANCE.md) owns the final sign-off. A passing
GameTest is server-side evidence; it is not proof of client rendering,
multiplayer timing, or every optional-mod combination.
## Automated evidence
| Area | Focused check | Candidate result |
| --- | --- | --- |
| Dedicated server, core linking, book/Age identity, old writing and workstation formats | `./gradlew --offline runGameTestServer`; includes `CoreGameTests`, `AgePersistenceGameTests`, `CrystalPortalGameTests`, M7 crafting/advancement checks | 91/91 required GameTests passed on NeoForge 21.1.264; clean shutdown and fresh-world removal. |
| Deterministic structures and terrain | `./gradlew --offline runGameTestServer -ParchivedGameTestSuites=generation`; includes library template/spacing/loot/stand checks, cave profiles, vanilla structures, crystal formations, fissures | 129/129 passed after correcting one village test to assert its own contribution rather than a seed-dependent total; fresh-world removal. |
| Sky, glyph numbers, seasons, creative/JEI page feed | `./gradlew --offline runGameTestServer -ParchivedGameTestSuites=environment` | 103/103 passed on the 0.6.11.0 candidate, including issue #94's number-page check; fresh-world removal. |
| Packaged models and data | `Milestone7ResourceGameTests.coreModelsResolveBundledTextures` in the core suite; parses critical block models, checks their Rivencraft texture references and the library NBT resource | Passed in the 91-test core run. This checks resource presence, not visual orientation or transparency. |
| Recipe behavior | `Milestone7CraftingGameTests` and `LinkingBookRecipeGameTests` through the loaded Recipe Manager | Passed in the core run; confirms crystal and receptacle quantities, ink bucket return, no Blank Page crafting, and Linking Book ingredients. |
| Portal state/save-load | `CrystalPortalGameTests` plus `AgePersistenceGameTests` in the core suite | Passed; covers activation/release, fallback orientation, horizontal/underside placement, uninterrupted surface, cooldown, retired Age and seed/plan identity. Full server restart remains manual. |
The generation archive initially failed
`AgeStructureGameTests.mixedEndAndTaigaVillagesChargeForEndPortion` because it
compared the total instability score (1200) with the village-only expectation
(200). A seed-completed sky legitimately supplied an extra sun (+1000). The
production scoring was unchanged; the check now isolates the
`forced_biome_villages` contribution. The rerun passed 129/129. This is a
repaired assertion, not a hidden feature failure.
Every default GameTest run creates a new world with Minecraft's fixed seed 0
and deletes it after pass or fail. See [GameTest guidance](GAMETESTS.md).
Do not retry a storage-stalled GameTest: stop it, remove that automated
scenario, and give its exact steps to the player. No run in this issue has
stalled so far. The older full-archive storage stall is historical and is not
counted as a current pass. On the subsequent **1.0.0.0 release candidate**,
the environment archive stalled during its first 50-test batch and was
stopped. The 103/103 result above applies to 0.6.11.0 only, not to 1.0.0.0
or the later 1.0.0.1–1.0.0.2 candidates. For the 1.0.0.2 manual handoff, create an Age
with Number Pages immediately before Celestial Angle, Phase, and Size pages;
confirm each affects the intended body rather than becoming a stray page. Also verify a
fixed-season Age remains fixed across a restart while the Overworld remains
independent. Do not rerun the stalled suite merely to obtain a green count.
## Old-save boundary (#60)
Issue #60 established a policy of preserving old saves where practical. Tests
cover bare-list `OrderedWriting`, page-free books, unknown symbol IDs, older
Age definitions with stable defaults, plan-v3/plan-v4 interpretation, and
workstation inventory migration. A bound book's UUID and its Age's registered
dimension remain stable through ordinary load. This does **not** imply every
pre-public-release world, removed biome mod, missing dimension provider, or
changed datapack can be opened. Back up the full world; the Age registry and
dimension folders are a unit. The creative Link Modifier can intentionally
retire an Age, archiving its data and marking the UUID dead. That is an explicit
administrative boundary, not a silent migration.
## Manual matrix for the player
Use a backup or disposable save. Record the exact mod list and jar version.
These checks are **pending for 0.6.11.0** unless a later note records an actual
pass. Historical passes on earlier slices should not be silently promoted to
this candidate.
| Scenario | What to verify |
| --- | --- |
| Full restart | Create a random and an authored Age, a library with a looted chest/page stand, an active portal, and a bound book. Stop and restart the server; UUID, terrain, loot removal, stand contents, portal activation, and return travel must persist. |
| Two players | Cross the same portal at nearly the same time, return independently, and use separate book previews. Cooldowns, destination positions, dropped-book ownership/retrieval, and preview chunk loaders must not interfere. |
| Cross-dimension travel | Compare the same Descriptive Book by hand, stand, and portal. Check footing/platform rules, arrival coordinates and facing, Disarm, Maintain Momentum, Intra Linking, and Return Link mode. |
| Client visuals/audio | Compare book spreads, glyphs, D'ni numbers, page/desk/binder/mixer models, 16×16 crystal buds and translucent elements, portal frame/surface order, skies/eclipses, page turns, and Legacy link sounds. A server test cannot observe pixels or audio. |
| Optional renderers | With neither Create nor Immersive Portals, confirm Legacy fallback. With Create alone, confirm Ponder snapshots and portal guide. With Immersive Portals, confirm live view, Explore exit, sky/sound isolation, portal travel, and fallback while chunks load. With both, `AUTO` should choose Immersive Portals. |
| Optional world/recipe mods | Test JEI guides/search, TerraBlender with a biome-producing addon (for example Biomes O' Plenty), Serene's six season modes plus Overworld independence/restart, and Ruins template selection/spawn weighting. Repeat startup without optional mods. |
| Migration | Copy a backed-up older development world. Verify books and Notebooks retain their Age/page IDs, a desk and stand keep contents, old Ages load or provide a clear error, and removing a custom symbol retains its unresolved ID. Do not test by modifying the only copy. |
| Binder diagnostics | As a non-OP, verify the Binder still creates books but shows no instability/seeded/status messages. As an OP, run `/rivencraft binder messages enable`, reopen the Binder, and verify those messages appear only for the OP. Disable them and restart the world; the setting should remain disabled. |
Historical evidence: the player approved NeoForge 21.1.264 on singleplayer
and a dedicated server with Immersive Portals; JEI 19.69.0.457 guides, Serene
Seasons 10.1.0.9's full per-Age matrix, Ruins generation, and issue #94's
numbered celestials each received an in-game pass on their respective earlier
release slices. See [compatibility review](MILESTONE_7_COMPATIBILITY_REVIEW.md),
[JEI review](MILESTONE_7_JEI_REVIEW.md), [Serene review](MILESTONE_7_SERENE_REVIEW.md),
and [Ruins notes](RUINS_COMPATIBILITY.md). The current candidate's broad
multiplayer/restart/optional-mod matrix remains unverified until tested.
+31
View File
@@ -0,0 +1,31 @@
# Serene Seasons 10.1.0.9 compatibility review (#88)
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
now compiles against [Serene Seasons 10.1.0.9](https://modrinth.com/mod/serene-seasons/version/pHEgQQUE)
with [GlitchCore 2.1.0.2](https://modrinth.com/mod/glitchcore/version/S2TfWrZR).
Both remain optional. The Serene update fixes a precipitation-mixin recursion;
it does not introduce a new dynamic-dimension API.
The 10.1.0.8 and 10.1.0.9 bytecode for Rivencraft's version-sensitive
`SeasonsConfig`, `SeasonHandler`, `SeasonSavedData`, and `SeasonTime` integration
classes is identical. In particular, the dimension whitelist field, per-level
saved season clock, and season update method have unchanged signatures and
implementations. The adapter still writes the whitelist through GlitchCore's
config API and sends Rivencraft's whitelist update to already connected clients.
Verification:
| Check | Result |
| --- | --- |
| Build against 10.1.0.9 and GlitchCore 2.1.0.2 | Passed. |
| Real Prism client with Serene 10.1.0.9, GlitchCore 2.1.0.2, and Rivencraft 0.6.6.7 | Both mods loaded and the player joined an Age. |
| No Seasons, normal Seasons, and all four Eternal modes in new and saved Ages | Player reported the full in-game matrix passed. |
| Reconnect, restart, and Overworld independence | Player reported the full in-game matrix passed. |
| Isolated dedicated-server startup without Serene/GlitchCore | Reached `Done` on port 25568 with only required runtime mods, then saved and stopped. |
| Isolated dedicated-server startup with Serene/GlitchCore | Reached `Done` on port 25569 with 10.1.0.9/2.1.0.2, then saved and stopped. |
Serene's sync packet is incompatible with GameTest mock players, so its
in-game behavior must be checked with a real client and server. The seasonal
test path in [SERENE_SEASONS_COMPATIBILITY.md](SERENE_SEASONS_COMPATIBILITY.md)
remains the regression checklist for future upgrades. Rivencraft does not
bundle or require Serene Seasons or GlitchCore.
+63
View File
@@ -0,0 +1,63 @@
# Milestone 7 visual and audio polish (#80)
The source is the local Mystcraft 0.13.7.06 extraction under
`/home/dnorberg/Documents/mystcraft-src/mystcraft-1.12.2-0.13.7.06/`.
Reused textures and OGG files are recorded in [asset provenance](ASSET_PROVENANCE.md).
The `dev images/book binder.png` and `dev images/ink mixer.png` screenshots
are the Legacy model references used for this slice.
## Legacy comparison and modern boundary
- The Crystal Block, Book Receptacle, and 16×512 portal animation already use
Legacy sprites. The Receptacle's 16×16×6 model already matches the Legacy
slab. The fallback portal now uses Legacy's four-pixel-deep, double-faced
`linkportal_ns.json` geometry, with the original animation; it declares a
translucent render layer and portal particle texture. The block remains
non-occluding. When Immersive Portals supplies a live view, the fallback
model is hidden, while the crystal frame is drawn over the view.
- The Age arrival platform now reuses the same Legacy crystal tile as Crystal
Block. It is an opaque, occluding full block, while retaining its empty loot
table and unobtainable pick-block behavior. It has glass-like 0.3 hardness.
The old cloud sprite is unused and removed.
- The Book Binder now includes the two inner braces and central press/book
piece omitted by the earlier model, using the existing byte-identical
Legacy textures. The Ink Mixer uses Legacy's `inkwell_model.json` geometry
and four original inkwell textures. A thin white paper surface replaces the
black paper appearance from the reference; the old simplified mixer tiles
are no longer used. The half-alpha glass atlas is intentionally rendered on
a cutout layer; its more opaque look passed the player's in-game check.
- Legacy's link listener selects Disarm before a custom portal sound, then
Following, Intra Linking, and ordinary Link. Rivencraft has no Following
modifier yet, so it selects Disarm, portal, Intra Linking, or ordinary Link
in that order. The Disarm and Intra OGGs are byte-identical Legacy copies.
- Legacy sends 50 small neutral link particles in roughly a one-by-two-block
region at both departure and arrival. Its fixed-function particle class is
not portable to 1.21.1. Rivencraft uses a one-shot burst of 50 vanilla Cloud
particles at both ends, emitted only after successful server-authoritative
travel. There is no continuous portal-particle loop in the inspected Legacy
`BlockLinkPortal` class, so the portal surface itself adds none.
- The Legacy `textures/entity/linkbook.png` and `linking/pop.ogg` are inspected
but not copied in this slice. The custom dropped Linkbook entity is part of
#85; adding its assets before the entity exists would leave unused resources.
Library walls, shelves, chest, and webs use vanilla blocks in the template,
so there are no separate library art files to port.
## Manual visual/audio check
Use the same bound book in a held use and a Crystal Portal. Confirm the link
particles and audio play at both ends, and the portal landing matches the book.
Repeat with Disarm and Intra Linking panels to check the distinct sounds.
Compare the fallback portal, crystal frame, and Receptacle with Immersive
Portals absent; verify the animated surface is visible from both sides and
does not expose caves or other geometry through solid blocks. With Immersive
Portals installed, confirm the live view replaces only the fallback surface,
the frame draws in front, and no fallback texture covers the live view.
Create/Ponder is optional and must not affect either rendering path.
Check a freshly generated arrival platform from above and below: the crystal
tile should be opaque, break about as quickly as glass, and drop no block item.
Compare the Book Binder press and Ink Mixer cup/glass against the `dev images`
references; the mixer paper should appear white, and neither block should
show missing-texture checkerboards or see-through-world artifacts.
The player reported that the in-game visual and audio checks pass on 2026-10-19,
including acceptance of the cutout glass appearance and current block sounds.
+42
View File
@@ -0,0 +1,42 @@
# Rivencraft 1.0.1.2-alpha.1 — Minecraft 26.3
Experimental **NeoForge 26.3** port preview. This is a separate artifact from
the supported Minecraft 1.21.1 release, and it does **not** have feature
parity. Use a disposable test world or make a backup first. Do not migrate a
1.21.1 Rivencraft world to this alpha.
## Working in this alpha
- Rivencraft loads on Minecraft 26.3 / NeoForge 26.3.0.99 with Java 25.
- The first-party runtime Age backend creates separately seeded, persistent
prototype dimensions. The operator-only `/rivencraft_port age` commands can
create and inspect them.
- Runtime Age catalogs are synchronized and acknowledged before cross-dimension
travel. A missing or unacknowledged destination is refused.
- Operator-created Descriptive Books can enter prototype Ages.
- Handheld Linking Books can be named and bound to a location, then used from
their book GUI to return there. The destination data survives a restart.
- The Crystal Block and Crystal Shard are registered with reused art.
## Not yet ported
Normal authored Age generation, book crafting and workstations, Page symbols,
most blocks and items, portal systems, Link modifiers, the rotating destination
preview, instability, and optional mod integrations. The Linking Book GUI shows
a placeholder destination panel rather than a live scene. Generated Ages use
prototype vanilla terrain, not the player's authored Pages.
The [26.3 port status](MINECRAFT_26_3_PORT.md) and [open port issues](https://git.paddedcell.org/dnorberg/rivencraft/issues)
track the remaining work. The stable 1.21.1 release remains available
separately. Please file alpha bugs with the exact Minecraft, NeoForge and
Rivencraft versions, plus the relevant client/server log.
## Verification
- Clean 26.3 Gradle build passed.
- Disposable dedicated-server smoke test passed: two distinct Age seeds and
their terrain persisted across a restart.
- The user verified the handheld Linking Book GUI click and travel in-game on
2026-10-27.
These checks are **not** the full port acceptance matrix or a stability claim.
+37
View File
@@ -0,0 +1,37 @@
# Rivencraft 1.0.1.3-alpha.1 — Minecraft 26.3
Experimental **NeoForge 26.3** port preview. This is separate from the
supported Minecraft 1.21.1 release and does **not** have feature parity. Use
a disposable test world or make a backup first. Do not migrate a 1.21.1
Rivencraft world to this alpha.
## Changes since 1.0.1.2-alpha.1
- An unbound Descriptive Book now opens a naming screen. Binding it creates a
distinct prototype Age with a random seed; duplicate names receive a
numbered suffix.
- A bound Descriptive Book opens a book screen whose destination panel can be
clicked to enter its Age. The server validates the held book and waits for
the client's Age catalog acknowledgement before travel.
- Retired Age IDs are excluded when assigning a new book's Age name, so an
archived world cannot be silently reused.
## Still experimental
These Ages use prototype vanilla terrain. Page-authored generation, crafting
and workstations, most items and blocks, portals, Link modifiers, instability,
live destination previews, and optional mod integrations are not ported. The
book panel is a placeholder, not a rendered destination view. The
[port status](MINECRAFT_26_3_PORT.md) and [open port issues](https://git.paddedcell.org/dnorberg/rivencraft/issues)
track the remaining work. The stable 1.21.1 release remains available
separately.
## Verification
- Clean 26.3 Gradle build passed.
- Disposable dedicated-server smoke test passed: two distinct Age seeds and
their terrain persisted across restart.
- The player verified Descriptive Book binding and travel in game on
2026-10-29.
These checks are **not** the full port acceptance matrix or a stability claim.
+490
View File
@@ -0,0 +1,490 @@
# Minecraft 26.3 port status (issue #96)
Checked 2026-10-26. Minecraft Java 26.3 is the newest stable Java release with
NeoForge support. This is an engineering audit and the start of the port, **not
a claim that a 26.3 Rivencraft JAR is ready**. The 1.21.1 build remains the
release target until a separate 26.3 client and dedicated-server build passes.
The user wants both Minecraft lines maintained after the port, not a one-time
26.3 release. Gitea milestone "Dual-version support: 1.21.1 and 26.3"
tracks issues #97–#103 under parent #96.
## Version boundary
Minecraft 26.3/NeoForge uses Java 25, while Rivencraft 1.21.1 uses Java 21.
Build and publish separate artifacts; do not widen the Minecraft dependency
range of the 1.21.1 JAR. The official [26.3 release notes](https://www.minecraft.net/en-us/article/minecraft-java-edition-26-3),
[NeoForge 26.3 Maven metadata](https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml),
[NeoForge Java 25 setting](https://github.com/neoforged/NeoForge/blob/26.3.x/gradle.properties),
and [NeoForge migration primers](https://docs.neoforged.net/primer/docs/)
are the version sources. NeoForge 26.3.0.99 is the newest listed stable build
at the time of this check.
## Dependency availability
The entries below mean that a NeoForge 26.3 version is advertised by the
project, not that its API or runtime behavior has been validated with
Rivencraft. Recheck immediately before choosing versions for a port build.
| Integration | Role in 1.21.1 | 26.3 availability | Port decision |
| --- | --- | --- | --- |
| [Dynamic Dimensions AR Edition](https://modrinth.com/mod/dynamic-dimensions-ar-edition) | Required runtime Age loading | No; 1.21.1 only | Keep it for 1.21.1. Use a Rivencraft-owned runtime-dimension backend for 26.3. |
| [bad packets](https://modrinth.com/mod/badpackets) | Declared runtime dependency; no direct Java imports in Rivencraft | No advertised 26.3 version | Determine whether the new dimension backend needs it; otherwise remove it from the 26.3 artifact. Use NeoForge's own networking for Rivencraft packets. |
| [JEI](https://modrinth.com/mod/jei) | Optional recipe/page guides | Yes | Port and test its plugin against the 26.3 API. |
| [TerraBlender](https://modrinth.com/mod/terrablender) | Optional modded biome support | Yes | Port and verify biome discovery and Age generation. |
| [Serene Seasons](https://modrinth.com/mod/serene-seasons) and [GlitchCore](https://modrinth.com/mod/glitchcore) | Optional seasons | Yes | Port and repeat the per-Age seasonal matrix. |
| [Biomes O' Plenty](https://modrinth.com/mod/biomes-o-plenty) | Optional TerraBlender test addon | Yes | Test modded biome registration and decoration. |
| [Ruins (Structure Spawning System)](https://www.curseforge.com/minecraft/mc-mods/ruins-structure-spawning-system) | Optional authored ruins integration | Yes | Port and verify its generation hooks. |
| [Create](https://modrinth.com/mod/create) / bundled Ponder | Optional scene renderer | No advertised 26.3 version | Use Rivencraft's existing legacy-animation preview fallback for this target until a compatible release exists. |
| [Immersive Portals for (Neo)Forge](https://modrinth.com/mod/immersive-portals-neoforge) | Optional live views and portal teleport integration | No advertised 26.3 version | Use the fallback portal surface and normal Rivencraft linking. Do not claim live destination views on 26.3. |
Ecliptic Seasons publishes 26.3, but Rivencraft's Ecliptic integration was
deferred and is not a replacement for the current Serene integration.
The alternatives checked for runtime Age loading do not meet the 26.3
NeoForge requirement: [upstream Dynamic Dimensions](https://github.com/TeamGalacticraft/DynamicDimensions)
and its [AR fork](https://github.com/dercodeKoenig/DynamicDimensions)
have no published 26.3 branch or artifact;
[DimLib](https://modrinth.com/mod/dimlib) is Fabric-only and stops at 1.21.1;
and [DimensionsCore](https://www.curseforge.com/minecraft/mc-mods/dimensionscore-dynamic-dimension-world-management)
currently publishes only a Forge 1.20.1 file. Mods that add fixed dimensions
or portals do not supply the arbitrary per-book runtime worlds Rivencraft
needs. This is a search result, not proof that no other project exists.
## 26.3 backend design
The 26.3 port is a **fresh-save target**. It does not need to import 1.21.1
Age worlds or migrate Dynamic Dimensions' saved registry data. The 1.21.1
artifact will continue using that library. Within 26.3, however, a created
Age must retain its identity, seed, chunks and access across save/restart.
1. Keep the existing `AgeDimensionService`/`AgeDimensionBackend` boundary.
Implement a target-specific 26.3 backend without importing Dynamic
Dimensions or its seed mixin. The backend contract needs registration at
server start, create/load, unload and delete. Return a failure without
publishing a half-created Age if any step fails.
2. At server startup, reconstruct all non-retired Ages from Rivencraft's
`AgeRegistrySavedData` **before** players can join. For a newly bound book,
register its unique `rivencraft:age/...` dimension and construct a
`ServerLevel` from its authored chunk generator, dimension type and saved
Age seed. The seed must feed chunk random state, not only `getSeed()`.
3. Register the level with the server's level map and the appropriate
dimension registries, then synchronize the new dimension definition to
already-connected clients before sending a travel packet. Handle a
connecting client receiving every existing Age. Determine the minimal
26.3 registry and network hooks from the current NeoForge source; do not
assume `/reload` creates new running levels.
4. Initialize world border, view/simulation distance, forced chunks, spawn
settings, per-Age data storage and tick/save lifecycle as a normal level.
On unload or retirement, refuse while players occupy the Age, move any
remaining entities safely, flush pending chunk writes, detach the level,
and synchronize removal. Retirement must not delete another Age's data.
5. Review the MIT-licensed Dynamic Dimensions code as an implementation
reference. If any code is adapted, retain its copyright/license notice and
identify the derived files. Prefer the smallest backend Rivencraft needs
over a general-purpose library fork.
For gameplay systems, treat the 1.21.1 Rivencraft source and assets as the
porting baseline. Carry across unchanged rules, probabilities, recipes, and
textures directly; adapt only the Minecraft/NeoForge APIs that changed in
26.3. Record deliberate behavior gaps (such as prototype Age generation)
instead of silently replacing established mechanics with new ones.
The release gate is a 26.3 integrated-client **and** dedicated-server matrix:
create two Ages with distinct seeds, explore chunks, travel while another
client remains online, reconnect, restart, revisit saved chunks, reseed for
future chunks, unload/reload, retire one Age without harming the other, and
verify normal travel without optional mods. Then repeat relevant paths with
available integrations. A failed synchronization must be reported and leave
the player in their current level, not crash or strand them.
## Implementation order
1. Keep `AgeDimensionService` independent of a concrete runtime-dimension
implementation. The 1.21.1 Dynamic Dimensions calls now live in
`AgeDimensionBackend`; the `AgeDimensionSeedMixin` is 1.21.1-only.
2. Create a separate 26.3/Java 25 build target. Replace the dimension backend,
then migrate the main sources through the official NeoForge primers. Do not
publish a partial JAR with missing Age generation.
3. Port optional integration classes one at a time. Keep unavailable mods
optional and preserve their existing fallbacks. Upgrade JEI, TerraBlender,
Serene Seasons/GlitchCore, Biomes O' Plenty, and Ruins only in the 26.3
target; do not change the validated 1.21.1 dependency set.
4. Run compile, data/resource validation, new-world Age generation, Age
save/restart/reload, direct and portal/book travel, client rendering, and
dedicated-server tests on both targets. Test without optional mods, then
with each available integration. Require an in-game check before release.
The core blocker is **not** a version number change: 26.3 makes a major API
jump and the required 1.21.1 runtime-dimension library has no 26.3 build.
## Build isolation started in #97
The repository root remains the playable 1.21.1/Java 21 build. A separate
`ports/mc263` Gradle build now resolves NeoForge 26.3.0.99 and compiles with
Java 25. The build workflow has separate Java 21 and Java 25 jobs. Its JAR
task remains disabled; this does not satisfy #97's final artifact criteria.
## First core slice in #98
The 26.3 target now has real Rivencraft metadata and an entrypoint. It
registers the Crystal Shard and Crystal Block with their reused 1.21.1
textures/models plus the 26.3 item-model definitions. `build` passed, and a
dedicated development server loaded the mod and reached `Done` with a new
world and no optional mods. The server was then stopped. This validates only
the entrypoint/registry/resource foundation. A development client also loaded
Rivencraft and reloaded its resources without reported missing models or
textures. The client logged the host's missing `libflite.so` narrator library;
this is unrelated to Rivencraft. Both test processes were stopped after
startup. This does **not** validate in-world client rendering or Age
functionality. Age generators, data, networking, and client code still
need migration; #98 remains open.
## Runtime-level prototype in #99
The 26.3 target has a first-party server-side runtime-level loader and a
SavedData seed index. It constructs each level with a `LevelStem` seed override,
publishes the level only after construction, and restores indexed Ages when
the Overworld loads. A dedicated-server probe created two separate Ages with
seeds `123456789` and `987654321`; after generating chunks, their terrain
fingerprints differed (`2865094` and `3227421`). Both levels, their region
files, seeds, and fingerprints survived a server restart, and the startup log
shows restoration before `Done`. The temporary operator-only
`rivencraft_port age` command exists solely to test this non-distributable
port workspace.
This is **not** issue #99 completion. The loader currently reuses the vanilla
Overworld generator because issue #98 has not ported authored generators or
Age definitions. The synchronized operator travel probe below does not port
normal book travel. The 26.3 JAR task remains disabled.
## Runtime client-catalog prototype in #100
The 26.3 target now sends the current dimension-key catalog to each joining
client and broadcasts a new catalog when a prototype Age is created or
retired. Clients update their known-level set and acknowledge a version; the
server's `rivencraft_port age enter <name>` probe refuses travel until that
acknowledgement arrives and the target is still loaded. A stale acknowledgement
does not satisfy the gate. This follows NeoForge's play-payload registration
and client-handler separation. It is not a general dynamic-registry sync for
authored dimension types, which have not been ported.
The dedicated-server probe used two simultaneous 26.3 clients. Both
acknowledged a newly created Age without reconnecting; a second client entered
an existing runtime Age; a player saved inside an Age reconnected after server
restart and remained there. The operator travel probe searches near origin for
safe footing and creates a small Crystal Block platform if none is found.
This probe is not the final book landing implementation.
`rivencraft_port age retire <name>` refuses to retire an Age containing a
player, then saves and closes it, removes it from the active index, and
broadcasts the reduced catalog. On the dedicated test server, the client
acknowledged creation and retirement even when they happened back-to-back; a
late acknowledgement for the intermediate catalog was ignored. Travel to the
retired Age was refused without moving the player, and retiring an occupied
Age was refused. The #101 lifecycle slice below now archives exact Age data.
This remains a **prototype**, not completion of #100: normal Linking and
Descriptive Book travel, authored dimension-type/generator registration, full
client registry synchronization, and an integrated-client regression pass
must follow the core port. The 26.3 JAR remains disabled. The local test
server was stopped after verification.
## Runtime lifecycle prototype in #101
The operator-only backend probe now supports `age unload`, `reload`, `reseed`,
and `retire` in addition to `create`, `list`, `sample`, and `enter`. Unload and
retirement refuse a level containing players. A full chunk/entity save and
close handles other entities; Minecraft's shutdown-style ticket deactivation
and startup-style activation preserve forced chunks across runtime reloads.
The loaded-dimension catalog is rebroadcast after unload/reload/reseed and
retirement, with stale acknowledgements ignored.
On the dedicated server, a generated Age unloaded and reloaded with the same
terrain fingerprint. Reseeding `lifetest` from `24680` to `13579` kept the
previously generated origin fingerprint `2898472`; previously ungenerated
chunks at offset 100 produced fingerprint `2897795`, identical to a fresh
`13579` control Age and different from a fresh `24680` control (`2863099`).
After restart, `lifetest` still reported seed `13579` and its original origin
fingerprint. Three consecutive unload/reload cycles with a connected client
completed; that client acknowledged the final catalog and intermediate stale
acknowledgements were ignored. Unloading the player's current Age was refused.
A force-loaded chunk and a summoned armor stand with UUID
`[I; -872307724, 883641011, -1239011659, 796091120]` survived repeated
runtime unload/reload and server restart. The entity could be queried again
after its chunk finished loading. The probe retired a different Age,
`seedcontrol`: only its directory moved to the sibling `world-retired-ages`
archive, other Age directories stayed in place, and reloading/recreating the
retired ID was refused after restart. A second archival probe exercised the
tombstone-before-move sequence and again moved only its own directory. The
tombstone is persisted before the archive move so an interrupted archival
cannot silently revive an old Age; an ordinary move failure attempts to
restore the index and level. Ten more consecutive unload/reload cycles
completed on the dedicated server, with its open file-descriptor count steady
at 218 before and after. That is a narrow smoke check, not proof of no leak.
This still does **not** close #101. The prototype has not been integrated with
normal books or the full 26.3 Age definition, nor has a long-running resource
leak test or interrupted-archive recovery drill been completed. Its dev world
and archive are under ignored `ports/mc263/build`, not a release save.
## Optional-integration probe in #102
On 2026-10-27, the published NeoForge 26.3 versions checked were JEI
31.17.0.95, TerraBlender 26.3.0.0.9, Biomes O' Plenty 26.3.0.0.15,
GlitchCore 26.3.0.0.5, and Serene Seasons 26.1.2.0.7 (its file is labelled
for Minecraft 26.3). Ruins advertises 26.3.1NF. Create/Ponder and
Immersive Portals still had no advertised compatible version. These are
availability checks, not API-compatibility claims.
The 26.3 development target now has the operator-only
`/rivencraft_port compat` command. It reports loaded optional mod IDs and
the count of registered biome keys by namespace, so integration smoke tests
can distinguish a missing addon from a missing biome registration. It does
not change Age generation. A clean dedicated server loaded with no optional
mods and reported 67 vanilla biomes. A separate dedicated-server launch with
TerraBlender, Biomes O' Plenty, GlitchCore, and Serene Seasons loaded all four;
the probe reported 68 `biomesoplenty` biome keys, and TerraBlender logged its
Overworld and Nether regions. Both launches stopped cleanly.
Issue #102 remains open. The 26.3 target still lacks authored Ages, JEI
guides, per-Age season policy, Ruins hooks, book previews and crystal portals,
so the relevant integration and fallback acceptance tests cannot yet pass.
The probe must not be mistaken for feature support. The existing 1.21.1
integration code and dependencies remain unchanged.
## First player-facing bridge in #104
The 26.3 port now registers a Descriptive Book with the Legacy item texture
and a persistent, network-synchronized Age-ID data component. The operator-only
`/rivencraft_port age book <name>` probe grants a book for an already indexed
Age; an unbound book does not create a world. Using the bound book calls the
same server-authoritative, catalog-acknowledged landing path as the existing
`age enter` probe. A missing or unloaded Age refuses travel. This avoids
trusting a book-supplied seed or creating a new Age from a forged item.
The dedicated-server smoke test still passed after this bridge, and a 26.3
development client loaded the new item model and texture without a missing
resource warning. The host still reports a missing Linux `libflite.so`
narrator library; this is not a Rivencraft item error. The client was stopped.
The player confirmed right-click travel in game. Book component persistence
across restart still needs a player test; the headless smoke test cannot
exercise it. To test the
bridge in a disposable 26.3 world as an operator:
1. `/rivencraft_port age create booktest 123456789`
2. Wait for the Age catalog acknowledgement, then
`/rivencraft_port age book booktest`.
3. Right-click the granted book and verify arrival in
`rivencraft:age/booktest`. Restart client and server, then use the same
book again and verify it targets the same saved Age.
This is deliberately a **port probe**, not the final book workflow: the Book
Binder, naming UI, GUI preview, automatic return link, dropped-book entity, authored Age
plan, and Legacy landing rules are not yet connected. The bound book currently
remains in hand after travel. Do not distribute this target or mark #104 done.
For a local player test only, the normally disabled port JAR can be built with
`./gradlew -p ports/mc263 build -PallowIncompletePortJar=true`; its filename
retains `26.3-INCOMPLETE` and a `port-dev` version marker. This does not enable the normal
release artifact or imply that the full mod has been ported.
The first Prism test exposed a production-only catalog bug: Minecraft 26.3's
`ClientPacketListener.levels()` returned an immutable set, and the client
handler attempted to clear it. That threw `UnsupportedOperationException`
before the client could acknowledge the Age, so book travel was refused. The
handler now replaces the entire level set through a narrow access transformer
and acknowledges only after replacement. The corrected local test JAR is
`rivencraft-26.3-INCOMPLETE-0.0.0.1-port-dev.jar`. It built successfully;
the player used that JAR to verify actual travel in game on 2026-10-27.
### Linking Book return-path slice
The separate 26.3 target now registers a Linking Book using the Legacy bound
book sprite. `/rivencraft_port link book` grants an operator-only probe book
bound to the player's exact current dimension, coordinates, and facing. Its
destination is a persistent data component resolved by the server on use. A
missing dimension, unacknowledged catalog, same-dimension target without
Intra-Linking, out-of-bounds target, or obstructed arrival is rejected without
moving the player. This path does **not** generate a landing platform.
For a test, stand at an unobstructed spot in the Overworld and run
`/rivencraft_port link book`. Create and bind an Age Descriptive Book using the
steps above, travel into that Age, then right-click the Linking Book. It
should return to the exact bound spot and facing. Same-dimension use should
refuse. Repeat after a server restart to check component persistence. This
slice is `0.0.0.2-port-dev` and remains explicitly incomplete: normal
crafting/binding, modifiers, book GUI, origin-drop entity, and safe fallback
are still unported. The probe book remains in inventory after travel.
### Age-index durability after the first in-game return-link check
The first return-link test exposed a recoverable prototype failure: Seamless
Portals rejected a newly added runtime Age, the integrated server exited
abnormally, and a second Age created just before the exit was not present in
Rivencraft's saved index on restart. Its directory contained no saved chunks.
The `0.0.0.3-port-dev` slice flushes the index synchronously before announcing
newly created Ages to clients. It also flushes reseed changes. The dedicated
server smoke test now checks that both new Age IDs are already in the on-disk
index **before** it issues `stop`, then verifies both seeds/terrain after a
restart. This closes the ordinary interrupted-shutdown gap but is not a
substitute for a power-loss durability test or integration with other mods'
dynamic-dimension catalogs.
### Handheld Linking Book binding slice
The `0.0.0.4-port-dev` target adds a client-only book screen using the Legacy
book artwork. An unbound Linking Book accepts a player-chosen title; a
serverbound packet binds the held book to the player's *current* dimension,
coordinates and facing only after checking the book, title length and empty
target. A bound book opens the same screen and uses a server-validated
destination-panel click for travel. This replaces the earlier direct item-use
probe. The panel is a visible placeholder, **not** a live snapshot. The
rotating preview, completed book behavior (including origin drop), recipe,
modifiers, stand handling and other port systems remain unported.
To test this slice, use `/give @s rivencraft:linking_book` in a 26.3 test
world, stand at the intended return point, right-click the unbound book, enter
a name and bind. Travel to an Age, right-click the book again and click its
right-hand destination panel. Verify the named book persists across restart,
same-dimension travel is refused, and a replaced/removed held book cannot be
bound or used by a delayed screen packet. Do not treat this partial target as
a playable release.
The first `0.0.0.4-port-dev` in-game right-click exposed a 26.3 screen API
mistake: `Screen.extractRenderStateWithTooltipAndSubtitles` already calls
`extractBackground`, so the screen's second call raised `Can only blur once per
frame`. The `0.0.0.5-port-dev` fix removes that duplicate background call.
The next in-game check found that a bound Linking Book's right-hand panel did
not trigger travel: its target component was persistent but lacked a network
codec, so the client screen could not identify the book as bound. The
`0.0.0.6-port-dev` slice adds stream codecs for the Linking Book target and
Descriptive Book Age ID. Server-side held-item and destination checks remain
authoritative. This needs another in-game check before considering the slice
validated.
The `0.0.0.6-port-dev` retest showed the bound-book panel but left-clicking
did not even close the GUI. Minecraft 26.3's primary mouse button is
`InputConstants.MOUSE_BUTTON_LEFT` (value 1), while the port handler still
checked the 1.21.1 value 0. The `0.0.0.7-port-dev` slice uses the 26.3
constant. This still requires in-game verification of the full click/travel
path.
The player confirmed the `0.0.0.7-port-dev` bound Linking Book click/travel
path passes in game on 2026-10-27. The 26.3 target remains incomplete and is
not ready for a public release or closure of the parent port issue.
### Explicit technical alpha exception
At the player's request, `1.0.1.2-alpha.1` packages this limited 26.3 port as
an experimental alpha. It is **not** the full mod and does not pass the stable
release gate above. The alpha label and release notes must state that Ages use
prototype vanilla terrain, normal authoring and most content are absent, and
worlds should be disposable or backed up. The 1.21.1 stable release remains
independent; issues #96–#104 stay open until the full port and validation pass.
The next development slice, `1.0.1.3-alpha.1`, adds a basic Descriptive Book
screen and server-validated binding. Naming an unbound book creates a unique
prototype Age with a random seed; clicking the bound book's placeholder panel
uses the acknowledged-catalog travel gate. This is not authored Page binding
or a live destination preview. The player confirmed the Descriptive Book
binding and travel interaction in game on 2026-10-29.
The following `1.0.1.4-alpha.1` development iteration registers a small
Rivencraft creative tab for the two Books, Crystal Block, and Crystal Shard.
It builds and passes the dedicated-server Age smoke test. The player confirmed
the tab and four entries in game on 2026-10-29. No Page categories or
unported items are advertised in the 26.3 tab.
The next local `1.0.1.5-alpha.1` iteration selects Legacy's unwritten
Linking Book sprite until a target is bound, then the bound sprite. It also
restores the four-Crystal-Shard crafting recipe for a Crystal Block. Both
changes passed the player's in-game visual/crafting check on 2026-10-29.
The local `1.0.1.6-alpha.1` iteration ports Budding Crystal and the four
blue crystal growth stages, reusing the 1.21.1 sprites and vanilla amethyst
growth/harvest rules. Crystal clusters yield Shards, but natural crystal
formation generation remains unported. Test growth with a Creative or
command-placed Budding Crystal; the player passed the in-game check.
The local `1.0.1.7-alpha.1` iteration adds one persistent Page item with
unwritten and Link Panel variants. It reuses the existing Page/Link Panel
artwork and exposes only the Link Panel variant in its own creative tab,
matching the 1.21.1 separation of Link Pages from symbol pages. Blank Pages
remain command-only bases; the Ink Mixer, Book Binder and symbol writing are
not yet ported. The player passed the in-game visual and persistence check.
The local `1.0.1.8-alpha.1` iteration ports the Ink Vial item, its animated
sprite, and the black-dye/glass-bottle/water-bucket crafting recipe. Using
it with paper in the other hand prepares a Link Panel at the current 1.21.1
default cost of 2 ink from a 16-use vial. Ink remaining persists per vial;
an empty vial yields a glass bottle. The Ink Mixer, configurable ink cost,
and Book Binder remain unported. The player passed the in-game craft/use check.
The local `1.0.1.9-alpha.1` iteration ports the special shapeless leather +
Link Panel recipe for an unwritten Linking Book. The 26.3 recipe serializer
rejects blank Pages and extra ingredients and preserves a panel's custom name
without carrying its Page identity onto the Book. No Link Panel modifiers are
ported yet. The player passed the in-game crafting check on 2026-10-29. The
full Book Binder GUI and Age authoring remain the next larger port step.
The local `1.0.1.10-alpha.1` iteration adds the first three Link Panel
modifiers as a prerequisite to Binder authoring. All eight combinations of
Disarm, Maintain Momentum, and Intra-Linking are testable in the Link Pages
creative tab and persist as a synced component through leather crafting and
book binding. The server uses the book's flags when linking: Intra-Linking
allows same-dimension travel, Maintain Momentum rotates horizontal velocity
to the destination yaw, and Disarm drops player inventory at the departure
point only after a successful teleport. The Ink Mixer, additional Link Modifier
flags, and Descriptive Book panel propagation are not yet ported. In-game
testing of each modifier and their combinations passed in game. This local slice
must not be committed, pushed or published without the player's explicit request.
The next local `1.0.1.11-alpha.1` slice introduces the Book Binder block and
its first server-authoritative binding workflow. Leather and a Link Panel
produce a named, bound Descriptive Book for a random prototype Age; the output
is persisted in the Binder until collected. The panel's Disarm and Maintain
Momentum flags carry onto the book and apply after successful Age travel;
Intra-Linking is inert for Descriptive Books. This is not yet symbol-page
authoring or the full Legacy Binder interface. Build and dedicated-server
smoke pass. The player passed basic Binder crafting and travel in game, but
reported that typing E closed the GUI and asked us to reuse the 1.21.1 GUI
more faithfully. No
commit, push, or publication without an explicit request during alpha.
The local `1.0.1.12-alpha.1` follow-up ports the existing Binder layout's
top title field, flank slots, center Page strip, and pulsing red missing-panel
cue. It persists the title on the block entity and consumes focused keyboard
events before the container can treat E as inventory-close. Click the output
once to bind and again to collect the result. The full authored-symbol strip
and 1.21.1 grammar still await later port slices. The same slice adapts the
existing 1.21.1 Ink Mixer block, persistent basin, additive probabilities,
16-ink-per-panel exchange, virtual output, Legacy atlas and animated glyph to
the 26.3 storage, menu and GUI APIs. The build and dedicated-server Age smoke
pass. In-game Binder and Mixer verification is
pending; do not commit, push, or publish without explicit authorization.
The local `1.0.1.13-alpha.1` iteration moves the Binder's freshly assembled
book directly to the cursor so one output click both binds and takes it. Its
central strip now displays five Legacy page thumbnails, while only the Link
Panel is currently writable. The Mixer recomputes its virtual output in the
same server click as an input change to address an intermittent first-attempt
delay. The user's second Mixer attempt succeeded but exposed a cost mismatch:
the Mixer now spends one ink and one paper per Link Panel and resets its
additive chances on output pickup. Build passes; in-game retest is
pending. This alpha remains local until explicitly authorized for release.
The local `1.0.1.14-alpha.1` visual follow-up uses the new Binder dev images
as a reference: the page strip now grows only when a Link Panel is present or
carried, its required-panel cue appears above the strip, and the first page
can be clicked to place/take the panel. The stored page has a tooltip. The
rest of the 1.21.1 strip (symbol pages and scrolling) depends on porting the
symbol-page data and authoring pipeline, not just drawing more placeholders.
The local `1.0.1.15-alpha.1` follow-up changes the missing-panel cue to the
Link Panel's 16×16 item icon while preserving its red pulse and tooltip.
## Verification of this first slice
The 1.21.1 `compileJava` and `build` tasks passed offline after the backend
extraction. The GameTest server ran 93 tests; 92 passed, while
`twoblockdeskdropsonce` failed because breaking either desk half dropped zero
desks instead of one. That desk failure is outside the dimension-backend edit
and must be resolved or rechecked before a release. The 1.21.1 `build` task
passed again after the 26.3 registry slice.
+40 -16
View File
@@ -1,21 +1,41 @@
# Porting roadmap
For a concise current status, start with [current implementation](CURRENT_IMPLEMENTATION.md)
and [milestone summaries](MILESTONES.md). This file retains the original
roadmap and architectural rationale.
Rivencraft ports Mystcraft Legacy from Minecraft 1.12.2 to NeoForge 1.21.1.
The world-generation, dimension, registry, networking, and rendering APIs have
changed substantially, so the work is divided into independently testable
milestones.
## Porting principles
The primary goal is to reproduce verified Mystcraft Legacy behavior as closely
as practical. Legacy source and the supplied 0.13.7.06 build are the behavioral
reference for grammar, probabilities, ordering, defaults, visuals, and gameplay
effects. A difference should be deliberate and documented rather than inferred
from modern implementation convenience.
Minecraft 1.21.1 has different world heights, registries, threading, networking,
data-pack APIs, and server protection expectations. Where an exact port would be
unsafe, technically impossible, or incompatible with current Minecraft,
Rivencraft preserves the player-visible intent while using bounded modern
mechanics. New features may extend Legacy when they fit Age writing and linking,
but extensions must be identified separately from compatibility behavior and
must not silently change existing saved Ages.
## Project tracking
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
@@ -32,18 +52,23 @@ 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 are deferred until Milestone 5.
**Complete.** See [Milestone 4](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/9)
Ink Mixer for Link Panels. Symbol effects were deferred to Milestone 5.
**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 symbol combinations into modern biome, noise,
dimension-type, and world-generation configuration. Optional integrations
may add composable biome regions and seasonal behavior. See the
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.
**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. **In progress.**
See the [Milestone 7 regression matrix](MILESTONE_7_REGRESSION.md) and
[Legacy audit](LEGACY_M7_AUDIT.md).
## Architectural direction
@@ -61,17 +86,16 @@ live rendering when their dependencies are installed. The tested selection
matrix and integration boundaries are documented in
[PREVIEW_COMPATIBILITY.md](PREVIEW_COMPATIBILITY.md).
The writing system and Age grammar will build on the existing page-free Age
path. Page-free books remain a useful deterministic fallback and test fixture
after written symbol combinations become available.
The writing system and Age grammar build on the existing page-free Age path.
Page-free books remain a useful deterministic fallback and test fixture.
Milestone 4 delivered a versioned, data-pack-defined symbol catalog and an
immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These
contracts preserve unknown symbol identifiers and store author intent; they do
not interpret symbols into Age generation, which remains Milestone 5 work.
contracts preserve unknown symbol identifiers and store author intent;
Milestone 5 adds the server-owned interpretation and persistent Age plan.
The Ink Mixer is included in Milestone 4 after book assembly; it
uses paper and Ink Vials to make baseline Link Panels. Adding modifier
ingredients and symbol-driven link properties belongs to Milestone 5.
ingredients and symbol-driven link properties were delivered in Milestone 5.
## Provenance rules
+6 -3
View File
@@ -45,7 +45,7 @@ being captured in the preview.
## Supported combinations and upstream limits
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.251, Create
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.264, Create
6.0.10, and Immersive Portals 6.0.7. Core Age generation and the `LEGACY`
backend run without Create or Immersive Portals; the headless GameTest server
uses that dependency-free path.
@@ -66,8 +66,11 @@ unstable in a particular graphics or mod configuration.
For preview-related releases:
1. Run `./gradlew runGameTestServer -PwithArchivedGameTests=true` to verify the complete selection matrix,
core Age creation, persistence, and travel without optional renderers.
1. Run `./gradlew runGameTestServer` for the core selection matrix, Age
creation, persistence, and travel without optional renderers. Add the
relevant archives from [GameTest guidance](GAMETESTS.md); if a scenario
stalls, stop it and hand that exact check to a real client rather than
treating the interrupted run as a pass.
2. Open held Linking and Descriptive Books once with each available backend.
3. Open both book types from a Book Stand and confirm Explore is disabled.
4. With Immersive Portals, enter and leave Explore, travel between dimensions,
+18
View File
@@ -0,0 +1,18 @@
# Random Age selection (issue #41)
Page-free and Link-Panel-only books still make one random Age definition from the Age seed. A written page overrides its branch; random completion never rewrites the book. The resulting terrain, traits, season mode, biome pool, and feature choices are saved with the Age identity, so loading an existing Age does not reroll it. This change affects **new** Ages only.
The rank-weight calculation follows Mystcraft Legacy's `GrammarGenerator.buildRankWeights` in the user-provided 0.13.7.06 classes: starting at the highest numbered (rarest) rank, a rank's weight is at least one greater than the preceding rank's total divided by the number of entries at the current rank. A missing intermediate rank still advances the weight. The corresponding `SymbolRules` entries supply the ranks. This is a selection policy, not a port of Legacy's full grammar tree or its Minecraft 1.12 world generator.
| Branch | Relative tickets for supported choices |
| --- | --- |
| Terrain | Standard 6, Flat 5, Amplified/Island/Cave/Void 1 each. Cave and Island occupy the rare slots corresponding to Legacy Nether and End terrain. Additional data-pack runtime profiles default to the rare rank and cause the group weights to be recomputed. |
| Biome controller | Native/Single/Medium 3 each; Tiled/Grid/Tiny/Small/Large/Huge 1 each. The selected controller still applies terrain eligibility and minimum-biome rules. |
| Additional random biomes | After Rivencraft's existing controller minimum, each extra biome has a 2-in-3 continuation chance and a 1-in-3 stop chance, capped by the eligible biome pool (including optional TerraBlender additions). This mirrors Legacy's ranked biome-list branch without changing the project's authored-page minimums. |
| Lighting | Normal 3; Dark/Bright 1 each. |
| Weather | Normal 9; Fast/Slow/Off/Always/Cloudy/Rain/Snow/Storm 1 each. WeatherOn is named Always here. |
| Season (Serene Seasons only) | Seasons 6, No Seasons 5, each eternal season 1. These are Rivencraft ranks for features absent from Legacy; normal cycling is most common and eternal seasons are rare. No season policy is rolled without Serene Seasons. |
Random-feature selection remains bounded to two material features and two structures. Within those limits, surface/deep lakes have Legacy rank 1; lava lakes rank 2 as a separate new fluid choice; spheres/spikes/obelisks rank 3; and floating islands/tendrils rank 4. Rocks, which are a Rivencraft generalization, use rank 4. For structures, villages/strongholds/mineshafts use rank 1, dungeons/Nether fortresses rank 2, and newer Minecraft structures rank 3. Eligibility filtering still removes features incompatible with the selected terrain/biomes. Separate rare Skylands, Crystals, and Legacy-weighted Star Fissure rolls remain. This preserves Rivencraft's existing count limits rather than claiming exact parity with the full Legacy feature grammar. Existing saved random feature lists are retained. The current celestial stopped-time chance (1 in 7) follows the verified Legacy period ranks; other numeric celestial values still use Rivencraft's documented range.
Regression coverage: `RandomAgeWeightGameTests` (archived grammar suite) checks exact terrain rank tickets and stable selections across many seeds. Core Age persistence tests cover page-free and Link-Panel-only books, plan/trait round-trips, and migrated definitions. Binder and grammar archives cover page order, missing symbols, rejected assembly, and replayed output. The default GameTest runtime has none of TerraBlender, Serene Seasons, Ecliptic Seasons, JEI, Create, Ponder, or Immersive Portals installed. Optional-mod interactions and real unload/restart checks remain manual per `GAMETESTS.md`.
+21
View File
@@ -0,0 +1,21 @@
# Documentation map
Start with the player-facing [project README](../README.md). The two maintained
technical entry points are [current implementation](CURRENT_IMPLEMENTATION.md)
and [milestone summaries](MILESTONES.md). Use [GameTest guidance](GAMETESTS.md)
to run checks. The files below retain detailed design, provenance, and
version-specific test evidence; an older milestone record may describe a
former behavior or loader version.
| Milestone | Detailed records |
| --- | --- |
| M1–M3 | [Porting roadmap](PORTING.md), [Age administration](AGES.md), [Age profiles](AGE_PROFILES.md), [preview compatibility](PREVIEW_COMPATIBILITY.md), [random Age weights](RANDOM_AGE_WEIGHTS.md) |
| M4 | [Writing data](WRITING_DATA.md), [book presentation](DESCRIPTIVE_BOOK_PRESENTATION.md), [Ink Mixer audit](INK_MIXER_MODIFIER_AUDIT.md) |
| M5 | [Acceptance](MILESTONE_5_ACCEPTANCE.md), [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md), [writing grammar](WRITING_GRAMMAR.md), [symbol semantics](SYMBOL_SEMANTICS.md), [TerraBlender](TERRABLENDER_COMPATIBILITY.md), [Serene Seasons](SERENE_SEASONS_COMPATIBILITY.md) |
| M6 | [Acceptance](MILESTONE_6_ACCEPTANCE.md), [regression](MILESTONE_6_REGRESSION.md), [Legacy audit](LEGACY_INSTABILITY_AUDIT.md), [score](INSTABILITY_SCORE.md), [live blocks](LIVE_BLOCK_INSTABILITY.md), [runtime](INSTABILITY_RUNTIME.md), [diagnostics](INSTABILITY_DIAGNOSTICS.md), [status effects](INSTABILITY_STATUS_EFFECTS.md), [decay](INSTABILITY_DECAY.md), [environmental hazards](INSTABILITY_ENVIRONMENTAL.md), [cloud platform](AGE_CLOUD_PLATFORM.md) |
| M7 | [Acceptance](MILESTONE_7_ACCEPTANCE.md), [regression and manual matrix](MILESTONE_7_REGRESSION.md), [Legacy audit](LEGACY_M7_AUDIT.md), [visual/audio](MILESTONE_7_VISUAL_AUDIT.md), [advancements](MILESTONE_7_ADVANCEMENTS.md), [JEI](MILESTONE_7_JEI_REVIEW.md), [Serene review](MILESTONE_7_SERENE_REVIEW.md), [compatibility](MILESTONE_7_COMPATIBILITY_REVIEW.md), [Ruins](RUINS_COMPATIBILITY.md) |
[Asset provenance](ASSET_PROVENANCE.md) applies across milestones. Detailed
audits are intentionally retained for source attribution and migration
history; these three entry points replace the former practice of treating
every dated task note as a current installation guide.
+66
View File
@@ -0,0 +1,66 @@
# Optional Ruins template pages (issue #91)
Rivencraft supports Ruins 1.21.1.2NF as an optional mod. When it is installed,
every `.tml` template in Ruins' active `config/ruins_config/<generic-or-biome>/`
folders receives a separate Ruins page in Rivencraft's creative and JEI page
feeds. New templates added to Ruins' config appear after a restart; removing a
template makes its page unavailable to new books. With Ruins absent, no Ruins
pages or structure integration are active.
An authored Age with Ruins pages is admitted through Ruins' dimension gate.
Rivencraft also admits the authored templates through their individual
`dimensionsToSpawnIn` gates, which usually list only `overworld` and would
otherwise prevent them from loading in an Age. Ruins still owns chunk
inspection, spawn chance, surface validation, biome pools, and spacing.
Rivencraft filters Ruins' weighted
template choice to the exact pages in that Age and vetoes an unauthorized
template in Ruins' pre-spawn event. A page therefore selects *which* template
may appear; it does not guarantee a structure in every chunk. Manual
`/testruin` placements are unaffected.
Repeating a template page multiplies that template's Ruins selection weight by
its page count and adds one spawn attempt per chunk for each duplicate page.
The extra attempts are capped at 32 per chunk (or Ruins' configured count if
higher). Distinct template pages do not add attempts, and Ruins' spacing and
surface checks still apply. As with Rivencraft's standard
structure pages, the first three copies of each template are instability-free;
each additional copy adds 100 instability. Ruins retains its own biome and
surface checks, so the standard structure forced-biome penalty does not apply.
Ruins' event identifies a template only by its filename. If two active folders
contain the same `.tml` basename, Rivencraft cannot distinguish them and
disables all Ruins pages for that configuration. Give each template a unique
filename to restore support. Ruins must also be able to parse the template and
find a suitable surface in the selected biome; a page cannot repair an invalid
or biome-incompatible template.
The adapter and dimension/selection mixin are version-sensitive to Ruins
1.21.1.2NF. The published Rivencraft jar does not include Ruins. To include it
in a development run, use `-PwithRuins=true`.
## Manual check
1. Start a client with Ruins 1.21.1.2NF and Rivencraft 0.6.9.3. Confirm the
Ruins Pages tab contains one page per configured `.tml` file, and search a
distinctive template name in JEI.
2. Bind a new Descriptive Book with a Link Panel, Standard Terrain, a suitable
surface biome, and one Ruins page (for example ZombieHut). Enter the Age and
explore *unvisited* chunks. Ruins marks inspected chunks and will not retry
chunks explored under the earlier broken build. Only the selected Ruins
template should appear. Ruins'
own chance and distance settings still determine how far exploration takes.
3. For duplicate weighting, bind an Age with one copy of one template page and
three copies of a second template page valid in the same biome. Across enough
new candidate chunks, the second should be selected more often relative to
the first, and there should be two additional spawn attempts per inspected
chunk. Four copies of one template should add 100 instability, visible
through the OP Age instability diagnostic, while three copies add none.
4. Make another Age without a Ruins page and confirm Ruins structures do not
appear there. Restart the game and confirm the authored Age keeps its page
selection. Then remove Ruins and confirm Rivencraft starts normally without
a Ruins tab.
The Ruins-enabled GameTest server passed all 90 required tests and loaded the
template-dimension and attempt mixins. Its test world was removed afterward.
Use the client check above to verify actual Ruins generation and the increased
attempt rate.
+51
View File
@@ -0,0 +1,51 @@
# Serene Seasons 1.21.1: automatic Age whitelist (issue #38)
Serene Seasons has a [NeoForge 1.21.1 release, 10.1.0.8](https://modrinth.com/mod/serene-seasons/version/B6nTjh4b), with GlitchCore required ([2.1.0.2 for NeoForge 1.21.1](https://modrinth.com/mod/glitchcore/version/S2TfWrZR)). Its [1.21.1 source](https://github.com/Glitchfiend/SereneSeasons/tree/1.21.1) shows that each `ServerLevel` gets its own `SeasonSavedData` in that dimension's data storage. Season progress is keyed to the level's day time and saved separately, so an enabled Rivencraft Age can have a season independent of the Overworld and other Ages. The public [`SeasonHelper`](https://github.com/Glitchfiend/SereneSeasons/blob/1.21.1/common/src/main/java/sereneseasons/api/season/SeasonHelper.java) can read a level's current season; it does not provide a method to register a dimension or set its season.
The activation point is Serene Seasons' [`dimension_settings.whitelisted_dimensions`](https://github.com/Glitchfiend/SereneSeasons/blob/1.21.1/common/src/main/java/sereneseasons/config/SeasonsConfig.java). Its default contains only `minecraft:overworld`. On the server, GlitchCore [reloads and synchronizes](https://github.com/Glitchfiend/GlitchCore/blob/1.21.1/common/src/main/java/glitchcore/config/ConfigSync.java) this config when a player connects. Rivencraft adds successfully loaded Age IDs to Serene's in-memory list **and** its saved config so later logins retain them. Rivencraft also sends each newly added ID to players already connected. No restart is needed for a newly generated Age.
Serene Seasons 10.1.0.8 has no public dynamic-dimension registration hook, so the adapter uses its config object and GlitchCore's config writer directly. This is version-sensitive: verify this path when upgrading either mod. Rivencraft checks that Serene is installed before loading the adapter; neither dependency is required to run Rivencraft.
## Real-client test path
1. Install matching NeoForge 1.21.1 builds of Serene Seasons and GlitchCore on the server and client. Keep Rivencraft's `Weather: Normal` for this first test, so its own fixed/accelerated weather controller does not compete with seasonal weather timing.
2. While the client stays connected, create an Age. Run `/rivencraft age list` as an operator and note its ID (for example `rivencraft:age/winter_garden`). Confirm this ID appeared in `config/sereneseasons/seasons.toml` under `dimension_settings.whitelisted_dimensions`, with existing entries intact.
3. Without restarting or reconnecting, travel into the Age and check `/season get`; change its season with `/season set mid_winter` and check the Overworld and another Age separately.
4. Reconnect another client and confirm it receives the same whitelist. Restart the server and confirm both the whitelist entry and the Age's season persisted.
5. Build separate Ages with No Seasons, Seasons, and each Eternal page. Confirm No Seasons stays absent from Serene's whitelist, Seasons advances, and each Eternal Age remains in its named mid-season across several in-game days, a reconnect, and a server restart. Verify the Overworld still advances normally. The Eternal page should not stop the Age's sun or moon. With Serene removed, No Seasons should still bind, but the other five pages should refuse to bind with a clear message.
6. With Serene installed and `auto_whitelist_ages=true`, create several new Link-Panel-only Ages and Ages with symbols but no season page. `/rivencraft age list` shows each saved random `season:` choice. Check that No Seasons is absent from Serene's whitelist, Seasons advances, and each Eternal choice holds its season. Reopen the world and confirm the listed choices do not change. Without Serene, create another unpaged Age and confirm the list has no saved season choice for it.
For a local development run, `./gradlew runClient -PwithSereneSeasons=true` adds these two mods only to that run. The published Rivencraft jar does not include or require them.
The server config `rivencraft-server.toml` has `serene_seasons.auto_whitelist_ages=true` by default. Set it to `false` to manage Serene's whitelist manually. Disabling it does not delete Age IDs previously added to Serene's config. On startup, Rivencraft also enrolls existing Ages that successfully load, so installing Serene after creating Ages does not require editing IDs one by one.
## Seasonal pages
An Age may specify one of six mutually exclusive seasonal modes:
| Page | Intended Age behavior |
| --- | --- |
| No Seasons | Opt this Age out of seasonal effects and progression. |
| Seasons | Run the optional season mod's normal cycle, independently of the Overworld. |
| Eternal Winter | Hold this Age in winter. |
| Eternal Summer | Hold this Age in summer. |
| Eternal Fall | Hold this Age in fall/autumn. |
| Eternal Spring | Hold this Age in spring. |
The six pages are available in the Season creative tab and the same page feed used by JEI. The writing grammar accepts at most one seasonal page, and its choice is saved in the Age plan. No Seasons removes that Age's ID from Serene's whitelist, even if it was previously enrolled; the other explicit pages enroll it regardless of the automatic-enrollment setting. When a **new** Age has no season page and Serene Seasons is installed, Rivencraft chooses one of these six modes from the Age seed and saves that choice in the Age definition. A Link-Panel-only random Age is included. The choice does not change on reload or when the spawn moves. If Serene is absent at creation, no random seasonal modifier is assigned. Older Ages without a stored choice keep their previous normal-cycle behavior. The server's `auto_whitelist_ages=false` setting still prevents implicit (unpaged) modes other than No Seasons from enrolling the Age; explicit seasonal pages override it. If Serene Seasons is absent, No Seasons remains valid, but the other five pages reject **new** Age creation with a missing-dependency message rather than silently doing nothing. Existing Ages remain loadable with their saved pages, though their season effects are inactive until a supported season mod returns. Ecliptic Seasons integration was closed as unsupported for dynamic Ages in issue #39.
For an eternal mode, Rivencraft sets Serene's per-Age saved clock to the midpoint of the chosen season and resets it before it leaves that mid-season. Clients receive Serene's normal season sync. This does not freeze the Age's day/night cycle or change the Overworld's season. Eternal Winter does not guarantee snow in every biome.
## Current limits
- The Serene adapter uses Serene's internal saved season state because there is no public per-dimension season-freeze API. The real-client behavior above, including each eternal mode and No Seasons, still needs player verification. Serene's [temperature and precipitation hooks](https://github.com/Glitchfiend/SereneSeasons/blob/1.21.1/common/src/main/java/sereneseasons/season/SeasonHooks.java) only cool eligible biomes.
- Serene Seasons' [season tick](https://github.com/Glitchfiend/SereneSeasons/blob/1.21.1/common/src/main/java/sereneseasons/season/SeasonHandler.java) advances from a dimension's day-time changes. A Rivencraft Age with a stopped celestial cycle may also have a stopped season; this needs an in-game check.
- Rivencraft's non-Normal weather modes currently control weather in each Age independently. Their timing can conflict with Serene Seasons' weather-frequency changes. Ecliptic Seasons integration was closed as unsupported for live dynamic Ages in issue #39; simultaneous installation is not a supported target.
## Verification so far
`./gradlew build` passed for candidate 0.4.9.2, and the baseline plus archived environment GameTests passed (35 required tests). The archive now covers seasonal page visibility, exclusivity, and saved-plan persistence without Serene installed. With Serene Seasons 10.1.0.8 and GlitchCore 2.1.0.2 in the optional local runtime, the GameTest server previously crashed when Serene Seasons sent `sereneseasons:sync_season_cycle` to a GameTest mock player that had not negotiated its client payload channel. The stack trace originates in Serene Seasons' `SeasonHandler.sendSeasonUpdate`, through GlitchCore's packet handler and NeoForge's packet check. This is a GameTest mock-network limitation, not a completed absent/present compatibility check. Do not keep rerunning that suite with Serene Seasons; use the real-client test path above instead.
For candidate 0.4.10.1, `./gradlew build --offline` and the focused environment archive passed (36 required GameTests). The new regression check covers omitted-page selection, explicit-page precedence, no-Serene behavior, and save/reload. Actual Serene weather, whitelisting, and eternal-season visuals still require the real-client steps above.
The user subsequently reported an in-game pass for issue #38, recorded in its Gitea comment and release V0.4.9.2. The earlier candidate notes above describe the historical automated checks; the real-client matrix remains useful when upgrading Serene Seasons or GlitchCore.
+17 -8
View File
@@ -11,11 +11,16 @@ Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalo
"semantics": {
"role": "terrain",
"value": "example:highlands"
}
},
"poem": "Terrain Form Tradition Flow"
}
```
`name` and `description` are translation keys. `category` is for display and sorting; it does not determine grammar. `semantics.role` and its typed `value` are server-owned. The terrain value above names an Age runtime profile that a later compiler must resolve and validate. A syntactically valid resource ID alone does not guarantee the profile or feature exists.
`name` and `description` are translation keys. `category` is for display and sorting; it does not determine grammar. `semantics.role` and its typed `value` are server-owned. The terrain value above names an Age runtime profile that the compiler resolves and validates. A syntactically valid resource ID alone does not guarantee the profile or feature exists.
`poem` is optional, client-safe artwork metadata: exactly four alphabetic words, ordered top, right, bottom, left. The client layers their glyphs from the shared Legacy component sheet at runtime. GUI thumbnails use the full transparent artwork over their page backgrounds; item icons use the same artwork reduced from 16 to 13 pixels across, over the page texture. Unregistered words use a stable generated component combination; when a symbol has no poem, the six original built-in IDs retain their Legacy poems and other IDs produce four stable fallback words. This affects appearance only, never Age generation. Generated images are cached up to 256 entries and discarded on resource reload.
The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Legacy's `ModWords.constructNumber` defines 0 as component 1 and 1–24 by overlaying a unit mark (1–4) and a five-step mark (5–20) from the bottom row of `symbolcomponents.png` in the *same square*. Legacy's Writing Desk has 25 tabs (0–24) and scrolls its four-tab window **one tab at a time**. Thus the supplied Legacy screenshots labeled 1–4, 5–8, and so on show overlapping windows, not 88 distinct numerals. Rivencraft retains its previously requested four-at-a-time navigation and 88 tabs. Its additional tab numbers use positional base 25: each place is an overlaid Legacy digit, with the places side by side (25 = 1,0). The two glyphs are sized and positioned to fit the tab. Hovering reveals the decimal tab index for accessibility. The generated-word mechanism renders page-free Descriptive Book text as layered glyphs instead of Minecraft's enchantment font. The Age name stays readable.
| Role | Value type and allowed values |
| --- | --- |
@@ -23,17 +28,21 @@ Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalo
| `biome_controller` | `native`, `single`, `tiled`, `grid`, `tiny`, `small`, `medium`, `large`, or `huge`. |
| `biome` | Canonical namespaced biome ID. |
| `lighting` | `normal`, `dark`, or `bright`. |
| `sky` | `overworld`, `nether`, or `end`. |
| `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. |
| `season` | `no_seasons`, `seasons`, `eternal_winter`, `eternal_summer`, `eternal_fall`, or `eternal_spring`. Seasonal effects other than No Seasons require optional Serene Seasons. |
| `celestial` | `normal_time` (the first representable single-cycle behavior). |
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. |
| `angle_modifier` | JSON number in `[0, 360)` degrees. |
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler will convert to turns. |
| `feature` | Canonical namespaced feature-effect ID; future generation mapping must recognize it. |
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. An immediately preceding D'ni number page overrides it with digit × 0.08. |
| `angle_modifier` | JSON number in `[0, 360)` degrees. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler converts it to turns. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
| `size_modifier` | JSON scale in `[0.25, 3]`. An immediately preceding D'ni number page overrides it with a linear 0.25×–3× scale: digit 0 is 0.25× and digit 24 is 3×. Without a number, Small/Normal/Large remain 0.5×/1×/2×. |
| `block` | Canonical namespaced material ID for a compatible following feature; registered full-collision blocks and source fluids receive generated Pages. |
| `feature` | Canonical namespaced feature-effect ID; the compiler must recognize and validate it. This includes supported decorations and structures. |
These are schema bounds, not claims that Legacy shipped numeric config ranges. See the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source values and biome-count rules. Roles outside this table—including multiple suns, gradients, special structures, and hazards—are intentionally unsupported by schema v2. Their meaning requires a later versioned extension, not an unvalidated free-form field.
These are schema bounds, not claims that Legacy shipped numeric config ranges. See the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source values and biome-count rules. Roles outside this table—including multiple suns, gradients, and hazards—are intentionally unsupported by schema v2. Their meaning requires a later versioned extension, not an unvalidated free-form field. Recognized schema values can still be rejected by the current compiler when their runtime effect is unsupported, such as angle modifiers or direct Rain/Snow weather pages.
The server rejects a v2 definition with an unknown top-level or semantics field, missing/incorrectly typed field, unknown role or choice, malformed/non-canonical resource ID, or out-of-range/non-finite number. An invalid definition is logged with its file ID and excluded from the active catalog. Saved pages and books still retain that ID, allowing the player to recover them if the data pack is corrected. A reload atomically replaces the catalog rather than mutating saved writing.
Schema v1 definitions remain readable as **display-only** symbols; existing packs need not be rewritten merely to show or copy pages. A v1 definition cannot add a `semantics` field. To make one eligible for future Age compilation, publish a v2 definition with the **same file path and ID**, adding a valid role and value. Do not rename the page ID or reorder a book's `OrderedWriting`. The client receives only `SymbolDisplayMetadata` (ID, translation keys, category, resolved flag); semantic definitions are not serialized to it. This slice defines and validates meaning but does **not yet apply** written symbols to Age generation; issues #30 and #31 implement parsing and compilation.
Schema v1 definitions remain readable as **display-only** symbols; existing packs need not be rewritten merely to show or copy pages. A v1 definition cannot add a `semantics` field. To make one eligible for Age grammar parsing, publish a v2 definition with the **same file path and ID**, adding a valid role and value. Do not rename the page ID or reorder a book's `OrderedWriting`. The client receives only display metadata; semantic definitions are not serialized to it. The [ordered-writing parser](WRITING_GRAMMAR.md) validates new books, and the server compiles supported writing into a frozen Age plan before Binder inputs are consumed.
The six built-in definitions demonstrate terrain, biome-controller, lighting, weather, celestial, and feature roles. A third-party pack may add its own definitions under its namespace. Use an explicit semantic role even when the display category has the same name; the two fields serve different purposes.
+63
View File
@@ -0,0 +1,63 @@
# Optional TerraBlender biome compatibility (Milestone 5, issue #37)
Rivencraft targets TerraBlender NeoForge **1.21.1-4.1.0.8**. The official
[1.21.1 release](https://modrinth.com/mod/terrablender/version/4.1.0.8) and
[region API](https://github.com/Glitchfiend/TerraBlender/blob/1.21.1/Common/src/main/java/terrablender/api/Regions.java)
are available for this Minecraft version. TerraBlender is `compileOnly` and
is **not** required by the published Rivencraft jar. For a local development
run with TerraBlender, use `-PwithTerraBlender=true`.
TerraBlender's API registers Overworld and Nether biome *regions*; it does not
register arbitrary Rivencraft Age dimensions. Rivencraft therefore keeps its
own deterministic Age biome sources and uses a guarded adapter to read the
registered region biome IDs. Only biomes still present in Minecraft's biome
registry are accepted. TerraBlender Overworld region biomes extend the pool
for standard, flat, and sky-island Ages; Nether region biomes extend Cave Age
pools and are valid authored Cave biome pages. Vanilla Nether-tagged biomes
are also valid Cave biomes. This is generic: no Biomes O' Plenty IDs are
hard-coded.
Every registered biome receives a generated page in Rivencraft's Biome or Cave
Biome creative tab, and in JEI when JEI is installed. Cave-tagged, Nether-tagged,
and TerraBlender Nether-region biomes go in the Cave Biome tab. Existing vanilla page IDs such as
`rivencraft:biome/desert` are unchanged; modded biome IDs include their
namespace, for example `rivencraft:biome/example/orchard` for a biome named
`example:orchard`.
Authoring still validates the exact biome registry key. New Age plans freeze
the selected biome IDs, distribution, and seed, so a reload with the same mods
retains the result. If a modded biome later disappears, the Age loader warns
and uses an eligible fallback for that load without rewriting its frozen plan.
Restore the mod to recover the authored biome. Do not remove biome mods from
an existing play world without a backup.
## Compatibility matrix
| Setup | Expected result | Validation |
| --- | --- | --- |
| Rivencraft without TerraBlender | Vanilla biome pages and Age generation remain functional; no TerraBlender classes load on the core path. | Default GameTest server and build. |
| Rivencraft with TerraBlender 4.1.0.8, no biome addon | Same baseline pool; default TerraBlender regions add no modded biome IDs. | GameTest server with `-PwithTerraBlender=true`. |
| Rivencraft + TerraBlender + Biomes O' Plenty 1.21.1 | BOP registry biomes appear as distinct Biome pages, with Nether-region pages in the Cave Biome tab. Its Overworld regions can enter composed standard/sky-island Age pools; its Nether regions can enter Cave Age pools. | In-game check below; this combination is not bundled in automated tests. |
| TerraBlender present but its region API cannot be read | Log one warning and use Rivencraft's normal biome pool. | Guarded adapter fallback; intentionally not forced by GameTest. |
## In-game check requested for Biomes O' Plenty
Use a **new test world** with compatible NeoForge 1.21.1 builds of
TerraBlender and Biomes O' Plenty installed on both sides.
1. In the Rivencraft Biome and Cave Biome Pages creative tabs (and JEI, if installed), find
at least one BOP Overworld biome and one BOP Nether biome, respectively. The page tooltip
should identify the biome and not show an unresolved symbol.
2. Bind a Descriptive Book with a Link Panel, Standard Terrain, a two-biome
distribution page, and two different BOP Overworld biome pages. Travel to
the Age and confirm both named biomes can occur. Reopen the same world and
confirm the Age still uses the same authored biome pool.
3. Bind a second book using Cave Terrain and a BOP Nether biome page. The
Binder must accept the page, and the resulting Cave Age must use the
selected Nether biome rather than rejecting it as non-cave.
4. Separately, run Rivencraft without TerraBlender and confirm a Link-Panel-
only random Age can still be created and entered. Do not reuse the BOP test
world for this absence check.
The previous broad registry-enumeration GameTest stalled in the test server,
so the BOP-specific and client-side checks are deliberately handed off for
manual testing instead of retaining that stalled test.
+9 -11
View File
@@ -34,10 +34,9 @@ Invalid definitions are logged and excluded by the server. Menus and future
page synchronization must obtain resolved display metadata from the server;
clients never make a symbol valid by naming it.
Rivencraft ships a small catalog covering terrain, biomes, lighting, weather,
celestial behavior, and features. Its v2 semantics prepare for the full
Legacy-inspired grammar; writing still uses random Age fallback until the
parser and compiler are implemented.
Rivencraft ships a catalog covering terrain, biomes, lighting, weather,
sky, celestial behavior, and features. The parser and compiler apply supported
schema-v2 pages to newly created Ages; omitted branches retain seeded defaults.
## Persistent writing
@@ -54,9 +53,7 @@ book copies its writing and Age reference; merely copying it never creates a
new Age. The Book Binder creates the Age when its finished output is taken.
The Descriptive Book screen can inspect the ordered pages by turning them from
the bottom corners and identifies unresolved IDs using server-provided symbol
metadata. Until Milestone 5, the
saved symbols are informational only: Age creation uses the existing random
fallback and does not apply them to generation.
metadata. Page-free books continue to use random Age generation.
Removing a data pack never removes its IDs from saved writing. A missing ID is
resolved as client-safe **Unresolved Symbol** display metadata while retaining
@@ -90,10 +87,10 @@ placement. The surface draws the native 30×40 portrait page region from
Legacy's book UI atlas; symbols are layered at a uniform scale, while the
square item models remain unchanged in inventories.
A Notebook holds 27 ordered positions. Its pages, names, gaps, and unresolved
A Notebook holds 252 ordered positions. Its pages, names, gaps, and unresolved
IDs are saved on the item, so moving the Notebook between an inventory, Desk,
and Binder does not rearrange them. A loose Page in a Desk tab exposes just one
position; a Notebook exposes all 27. The Desk has 88 persistent collection
position; a Notebook exposes all 252. The Desk has 88 persistent collection
tabs, shown four at a time. Use the up/down tab controls to move by four, put
paper and Ink Vials in their input slots, select a stored page, and take the
copy from the virtual output. Taking it spends one paper and the configured
@@ -103,12 +100,13 @@ copying. Moving a page within the Desk or dragging it into the inventory removes
the original only when the destination accepts it.
The Book Binder takes a leather or empty-Notebook cover, an Age title, one Link
Panel, and optionally up to 27 ordered written Pages. A filled Notebook can be
Panel, and optionally up to 252 ordered written Pages. A filled Notebook can be
imported at a chosen strip position; the empty Notebook remains recoverable.
The preview result is virtual. Taking the completed Descriptive Book commits the
inputs once, creates its Age identity, and saves schema-versioned
`OrderedWriting` plus the Age reference on the book. Link-Panel-only books
create random Ages. A written symbol does **not** yet change that generation.
create random Ages. Supported written pages change the corresponding Age
generation branch; missing branches use seeded defaults.
Inputs, title, and pending Age identity survive Binder save/load; breaking the
Binder returns stored inputs. The completed book retains both its writing and
Age identity through item save/load and copying.
+25
View File
@@ -0,0 +1,25 @@
# Ordered Descriptive Book grammar (Milestone 5, issue #30)
`WritingGrammar` interprets the saved `OrderedWriting` on the server using only `SymbolCatalog`'s schema-v2 semantics. Client display metadata, page names, and supplied translations never determine an Age's meaning. The parser is pure: it returns immutable parsed entries and indexed diagnostics without changing a book or its pages.
New Binder writing has at most one Link Panel plus 252 symbol pages (the current fixed-slot save-format ceiling, not a Legacy gameplay limit). The Link Panel must be first. A Link-Panel-only book, or an older book with no writing component, keeps the existing random Age path. Any authored supported symbol makes the book a partial or complete grammar. From 0.4.3.0, the server compiles it into a persistent Age plan before consuming Binder inputs or generating chunks.
The supported order follows the representable subset of the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md): terrain; zero or more biome pages; biome controller; weather; season; lighting; sky effects; zero or more period or phase modifiers; one celestial page; then zero or more features, each optionally preceded by a compatible material page. Biome pages precede the controller that consumes them, and celestial modifiers precede the celestial page. Terrain, controller, weather, season, lighting, sky, and celestial are single-choice categories; identical repeats are duplicates and differing values conflict. Repeated modifiers, biome pages, feature pages, and material pages are intentional and retain their order. An unpaired material page receives a seeded compatible feature during compilation. Angle modifiers remain recognized in the schema but are rejected at binding until a sky renderer can apply sun tilt.
Missing terrain, controller, weather, lighting, celestial, or the controller's minimum biome count is an **incomplete** warning, not an error: the compiler retains seed-selected traits for missing branches. An omitted terrain page uses a seed-selected profile compatible with authored feature pages, so changing the Age name cannot accidentally select Flat, Void, or Cave terrain for Rocks or Lakes. If no profile can support the writing, binding still rejects it. Native consumes no authored biome pages; Single consumes one; Tiled/Grid and Tiny–Huge require two, with seeded filler when pages are missing. From 0.5.1.0, known misplaced or repeated pages no longer block creation: the book keeps its original order, while the server repairs category order, uses the last single-choice controller, and stores a writing-instability score in the Age plan. A conflicting/repeated choice adds 500; an out-of-order page or misplaced modifier adds 100. Native ignores surplus authored biome pages and Single uses the first, with the conflict recorded as instability. Audited Legacy symbol modifiers also add 500 for each Bright Lighting page and 100 for each Mineshafts, Nether Fortresses, Strongholds, or Villages occurrence after the third. Modern structures configured with the same rule are Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins. Every repeated structure page increases that structure's placement rate: probability-based structures multiply their chance, grid-based structures use a denser grid, and Strongholds add more positions to each ring and more positions overall. One page retains the vanilla placement rate. Authored structures can generate outside their vanilla biome set; each structure-page occurrence that requires this override adds 100 instability. Seed completion selects compatible biomes where possible. Other feature pages have no direct symbol cost. Incorrectly placed pages are **not** red-highlighted; their warning remains available as a tooltip. Unknown IDs, schema-v1 display-only symbols, unavailable dependencies/profiles, unsupported semantic effects or feature/material pairs, missing/extra Link Panels, and oversized writing still reject a new Age. The Binder disables output for those fatal errors and shows a red page indication where applicable. Rejected inputs remain removable.
The plan stores the original writing order, the authoritative semantic value of each resolved symbol, the active repaired-page indexes, the writing-instability score, the Age seed, and a copy of the selected runtime profile. An explicit terrain page overrides random terrain selection; an omitted terrain uses a seed-selected compatible profile. The built-in Standard, Amplified, Cave, Island, Flat, and Void terrain pages select their matching generation resources. Age loading uses the frozen runtime profile and does not need the original symbol definition or runtime-profile JSON, though its referenced noise, biome-preset, and dimension-type registry entries must remain available. Existing bound books without plans are not reinterpreted. Page-free and Link-Panel-only books keep the pre-existing random path. Biome and environment pages affect new Ages, and the supported feature subset below is compiled into the same frozen plan. Nonzero instability can select a persisted provider plan; status effects, decay, lightning, explosions, meteors, burning, and test-enabled erosion run through the bounded Age-scoped scheduler described in [the runtime policy](INSTABILITY_RUNTIME.md).
## Supported feature pages (issue #35)
Feature pages come after the celestial section. Their order is retained in the plan and in decoration placement. The Caves page adds a second, Age-seeded vanilla cave-carving pass to Noise terrain, including Standard, Amplified, Island, and Cave terrain. It chooses the vanilla Overworld or Nether cave carver from the source biome. This is separate from the Milestone 7 issue about Cave **terrain** generation. Flat and Void terrain have no suitable solid noise body and reject Caves with a binder diagnostic.
Flowers, Trees, Huge Trees, Rocks, Surface Lakes, Deep Lakes, and Lava Lakes add vanilla placed features after normal biome decoration. Huge Trees currently use vanilla old-growth spruce placement as a modern approximation of Legacy's huge-tree page; it is not the custom Legacy tree generator. Without a preceding material page, each material-bearing feature selects a valid solid block or source fluid from the registered pool. The selection is deterministic for the Age seed and feature occurrence, so repeated feature pages can receive different materials while remaining stable across restarts with the same block registry. The page-specific feature preserves vanilla placement rules other than the biome-list filter, since an authored page deliberately adds it to the Age. Surface and Lava Lakes use a more visible one-in-twelve-chunks attempt rate instead of vanilla lava lakes' one-in-two-hundred; Deep Lakes retain their underground placement. Vanilla biome lava lakes are separate and can still appear alongside authored fluid lakes. These decorations require surface Noise terrain; Cave, Flat, and Void reject them. Flowers and both tree pages require at least one surface Overworld biome and only run in compatible chunks; Nether, End, and tagged cave biomes do not receive surface vegetation. Ravines add a separate Age-seeded vanilla canyon-carving pass to Noise terrain. Unsupported feature IDs and missing vanilla registry entries produce diagnostics instead of becoming no-ops.
Material pages are generated for registered full-collision, non-fluid blocks and genuine source-fluid blocks, including eligible blocks supplied by other mods. Blocks with block entities are excluded. Waterlogged plants and coral are not fluid blocks and receive no pages. Valid source-fluid blocks appear only in the Fluid Pages creative tab; valid full-collision solids appear only in Block Pages. Both categories use the same saved material-page semantics. Put a Block Page before a compatible solid feature (Rocks, Floating Islands, Tendrils, Spheres, Spikes, or Obelisks), or a Fluid Page before a lake feature. A material page with no explicit feature receives a seeded compatible one. A material directly attached to an incompatible feature is rejected. The effective feature and material IDs are frozen with the Age, so future chunks retain their choices after restart. Existing chunks are not retroactively decorated.
Crystal Formations are an authored, material-free feature on exposed solid terrain, including Flat terrain; Cave and Void reject the page. Like Legacy, one in fifteen eligible chunks attempts a formation, with the site fixed by the Age seed and chunk coordinates. The modern formation is a compact mound with budding hearts instead of Legacy's long crystal lines. Budding blocks grow through small, medium, and large buds into clusters using vanilla amethyst timing and sounds. Non-silk cluster harvest yields Crystal Shards; four shards craft one Crystal Block. These blocks and shards use 16×16 textures: Legacy's crystal tile for solids and hue-shifted vanilla amethyst sprites for buds and clusters. A page-free Age can also randomly select Crystal Formations; an authored page always retains its exact choice in the saved plan.
Star Fissures are a material-free feature on every terrain profile, including Cave and Void. The fissure is generated once beside the first resolved spawn, using Legacy's spawn-chunk offset and 10–17 paired, irregular X-span rows running along +Z. Legacy's y=0 portal floor maps to the modern dimension's minimum build height, replacing lower bedrock if present. The rift opens to the sky on ordinary terrain; in Cave terrain it opens into the cavern but stops below the upper bedrock roof. Water in the carved columns is cleared before the portal is placed. Its non-solid portal links to the Overworld spawn with the Legacy fissure sound; the animated starfield is drawn with Minecraft's current End Portal renderer, which uses the same end-sky/end-portal art as Legacy's custom renderer. Random Ages sample Legacy's default small-feature grammar for fissure selection: the stop/one/repeat branches have rank-derived weights 1:1:3, and the fissure has weight 1 of 9 among its small-feature rules. The remainder of random feature selection still follows Rivencraft's current pipeline, rather than running Legacy's entire Age grammar.
The Rivencraft symbol-page creative tabs read a generated index of bundled symbol definitions and group Pages by each definition's category, except biome-controller roles, which appear in a separate Biome Generation tab rather than the Biome tab. Adding another valid JSON symbol under `src/main/resources/data/rivencraft/rivencraft/symbols/` includes its page in the matching category tab on the next build; a new bundled category receives its own tab. Registry-derived Block and Fluid Pages have separate tabs and both appear in optional JEI. Registry-derived biome pages share a creative/JEI feed; `terrablender:deferred_placeholder` is excluded because it is TerraBlender's internal staging biome, not a player-facing biome choice. Other modded biomes remain listed. Link Pages have a separate tab. Blank Pages appear in neither the creative tabs nor JEI's ingredient list. The server catalog still decides whether a page is valid for writing. External server data-pack symbols are not included in this bundled index.
+6 -3
View File
@@ -18,11 +18,11 @@ minecraft_version=1.21.1
# as they do not follow standard versioning conventions.
minecraft_version_range=[1.21.1]
# The Neo version must agree with the Minecraft version to get a valid artifact
neo_version=21.1.251
neo_version=21.1.264
ponder_version=1.0.82
dynamic_dimensions_file_id=7707789
badpackets_version=0.8.2
jei_version=19.57.0.449
jei_version=19.69.0.457
# The loader version range can only use the major version of FML as bounds
loader_version_range=[1,)
@@ -36,8 +36,11 @@ 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.4.1.0
mod_version=1.0.1.1
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=pHEgQQUE
glitchcore_version_id=S2TfWrZR
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
+103
View File
@@ -0,0 +1,103 @@
# Minecraft 26.3 port workspace
This is an **experimental alpha port** for issues #97–#104, not gameplay parity
with the Minecraft 1.21.1 release. It registers basic items and a runtime Age
prototype with persisted seeds. The operator-only `rivencraft_port age`
commands create and inspect separate vanilla-terrain levels. The current
development slice can name an unbound Descriptive Book to create a prototype
Age, then enter it from the book GUI; the player verified this interaction in game.
Named Linking Book binding/travel works as a limited probe. Authored
generation, most content, recipes, workstations, portal systems, most modifiers and
the destination preview renderer remain unported. Use a test world and keep a
backup; do not substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
The creative tab contains the two Books, Crystal Block and Crystal Shard;
the player verified these in game. Unbound Linking Books use the Legacy
sprite, and four Crystal Shards craft into a Crystal Block; both were also
verified in game. The current local iteration adds Budding Crystal and all
four blue crystal growth stages with vanilla-style harvesting. Natural crystal
formation generation is not yet ported; use Creative or commands to obtain a
Budding Crystal for this test. The player verified the growth chain in game.
The next local slice adds the Link Panel Page as a separate creative entry,
using the existing item texture. Blank Pages remain unavailable in Creative;
the player verified the Link Panel's appearance in game. Ink Vials now use
the existing crafting recipe and animated sprite. With paper in the other
hand, an Ink Vial prepares a Link Panel for two of its sixteen ink uses, as
in the current 1.21.1 default. The player verified the Ink Vial interaction
in game. Symbol writing remains unported.
A prepared Link Panel can now be combined shapelessly with leather to craft
an unwritten Linking Book, as in 1.21.1. This custom recipe rejects a blank
Page and extra ingredients. The first Book Binder slice now assembles random-Age
Descriptive Books from leather and a Link Panel. Authored pages remain later.
The next local `1.0.1.10-alpha.1` slice exposes all eight combinations of
plain, Disarm, Maintain Momentum, and Intra-Linking Link Panels in the Link
Pages creative tab. Leather crafting preserves those flags on the Linking
Book. Intra-Linking permits same-dimension travel; Maintain Momentum rotates
horizontal speed to the destination yaw; Disarm leaves the player's inventory
at the departure point after a successful teleport. These are development
variants until the Ink Mixer is ported; other Link Panel modifiers are not
implemented in 26.3. The player passed this modifier slice in game.
The local `1.0.1.11-alpha.1` slice adds a craftable Book Binder with persistent
leather, Link Panel, and output slots. Name an Age in its GUI and press Bind;
the server creates a random prototype Age and places its completed Descriptive
Book in the output. Disarm and Maintain Momentum carry from the panel into the
book; Intra-Linking has no effect on Age creation. Symbol-page authoring and
the Ink Mixer remain unported. This slice passes the build and dedicated-server
smoke test. The player passed crafting and travel in game, but found that E
closed the focused title field and the provisional layout did not match the
existing 1.21.1 Binder GUI.
The local `1.0.1.12-alpha.1` follow-up reuses the 1.21.1 Binder layout: title
bar, cover and output at opposite sides, and a central Link Panel page strip.
The title persists in the block, the missing-panel cue pulses red, and a
focused title field consumes inventory/hotbar keys. Click the output once to
bind, then click again to take the completed book. Authored symbol strips and
the full 1.21.1 Binder grammar remain unported. This same slice also ports the
1.21.1 Ink Mixer block, menu, animation, modifier additives, 16-ink-per-panel
cost, and automatic vial exchange. The creative tab includes it and its
vanilla-shaped recipe is available. This slice needs an in-game Binder visual/
keyboard check and Ink Mixer interaction check.
The local `1.0.1.13-alpha.1` iteration makes Binder assembly place the new
Descriptive Book directly on the cursor with one click. It also renders the
five-page strip from the 1.21.1 GUI and refreshes the Mixer's virtual result
in the same server click that changes paper or ink. The Mixer now consumes
one ink unit and one paper per Link Panel (16 panels per full vial); additive
chances reset when a panel is taken. In-game retest of the first-update
behavior and Binder visual/click flow is pending.
The local `1.0.1.14-alpha.1` follow-up compares the current 26.3 Binder against
the new dev-image sequence. The Binder no longer paints five permanently empty
pages: the first strip page appears when a Link Panel is inserted or carried,
and the missing-panel cue remains above it. Clicking that page inserts/removes
the Link Panel using the same server-owned cursor flow as 1.21.1. Its tooltip
also shows the stored panel. Multi-page symbol writing and strip scrolling
cannot be exercised yet because symbol pages are still unported to 26.3.
The local `1.0.1.15-alpha.1` Binder polish uses the Link Panel's actual 16×16
item icon for the missing-panel cue instead of a full-size page sprite. The
red pulse and tooltip remain confined to that icon.
From the repository root:
```sh
./gradlew -p ports/mc263 build
```
This standalone Gradle build uses Minecraft 26.3, NeoForge 26.3.0.99,
ModDevGradle 2.0.148, and Java 25. The repository root remains the independent
Minecraft 1.21.1/Java 21 build:
```sh
./gradlew build
```
The alpha JAR is under `ports/mc263/build/libs`. Continue the port and its
full dual-version validation in issues #98–#104 before calling 26.3 stable.
The dedicated development server uses `build/run-server`, with its own world
and properties. Set a nonconflicting `server-port` there before running if
another Minecraft server is active. The prototype restores all indexed Ages
during the Overworld load, before initial chunk preparation completes.
+131
View File
@@ -0,0 +1,131 @@
plugins {
id 'java-library'
id 'net.neoforged.moddev' version '2.0.148'
}
group = 'net.dnorberg.rivencraft'
version = mod_version
base {
archivesName = "rivencraft-${minecraft_version}"
}
java.toolchain.languageVersion = JavaLanguageVersion.of(25)
neoForge {
version = project.neo_version
runs {
client {
client()
if (project.hasProperty('connectAddress')) {
programArgument '--quickPlayMultiplayer'
programArgument project.property('connectAddress').toString()
}
if (project.hasProperty('clientName')) {
programArgument '--username'
programArgument project.property('clientName').toString()
gameDirectory = project.layout.buildDirectory.dir('run-client-' + project.property('clientName')).get().asFile
}
}
server {
server()
gameDirectory = project.hasProperty('serverRunDir')
? file(project.property('serverRunDir').toString())
: project.layout.buildDirectory.dir('run-server').get().asFile
programArgument '--nogui'
}
}
mods {
rivencraft {
sourceSet(sourceSets.main)
}
}
}
def generateModMetadata = tasks.register('generateModMetadata', ProcessResources) {
def replaceProperties = [
mod_id: mod_id,
mod_name: mod_name,
mod_license: mod_license,
mod_version: mod_version,
neo_version: neo_version,
minecraft_version_range: minecraft_version_range,
]
inputs.properties replaceProperties
expand replaceProperties
from 'src/main/templates'
into 'build/generated/sources/modMetadata'
}
sourceSets.main.resources.srcDir generateModMetadata
neoForge.ideSyncTask generateModMetadata
tasks.named('processResources', ProcessResources).configure {
// Reuse the existing sprite and base model without copying binary assets.
from('../../src/main/resources') {
include 'assets/rivencraft/textures/item/crystal_shard.png'
include 'assets/rivencraft/textures/item/page.png'
include 'assets/rivencraft/textures/item/page_link_panel.png'
include 'assets/rivencraft/models/item/page_link_panel.json'
include 'assets/rivencraft/textures/item/ink_vial.png'
include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta'
include 'assets/rivencraft/models/item/ink_vial.json'
include 'assets/rivencraft/models/item/crystal_shard.json'
include 'assets/rivencraft/textures/item/descriptive_book.png'
include 'assets/rivencraft/models/item/descriptive_book.json'
include 'assets/rivencraft/textures/item/linking_book.png'
include 'assets/rivencraft/textures/item/linking_book_unwritten.png'
include 'assets/rivencraft/models/item/linking_book_bound.json'
include 'assets/rivencraft/textures/gui/bookui_cover.png'
include 'assets/rivencraft/textures/gui/notebook_pages.png'
include 'assets/rivencraft/textures/gui/bookui_pager.png'
include 'assets/rivencraft/textures/block/crystal_block.png'
include 'assets/rivencraft/textures/block/book_binder.png'
include 'assets/rivencraft/textures/block/book_binder_press.png'
include 'assets/rivencraft/textures/gui/book_binder_legacy.png'
include 'assets/rivencraft/textures/gui/ink_mixer_legacy.png'
include 'assets/rivencraft/textures/block/inkwell_base.png'
include 'assets/rivencraft/textures/block/inkwell_cup.png'
include 'assets/rivencraft/textures/block/ink.png'
include 'assets/rivencraft/textures/block/ink_glass.png'
include 'assets/rivencraft/models/block/ink_mixer.json'
include 'assets/rivencraft/models/item/ink_mixer.json'
include 'assets/rivencraft/blockstates/ink_mixer.json'
include 'data/rivencraft/loot_table/blocks/ink_mixer.json'
include 'assets/rivencraft/models/block/book_binder.json'
include 'assets/rivencraft/models/item/book_binder.json'
include 'assets/rivencraft/blockstates/book_binder.json'
include 'data/rivencraft/loot_table/blocks/book_binder.json'
include 'assets/rivencraft/models/block/crystal_block.json'
include 'assets/rivencraft/blockstates/crystal_block.json'
include 'assets/rivencraft/blockstates/budding_crystal.json'
include 'assets/rivencraft/blockstates/small_crystal_bud.json'
include 'assets/rivencraft/blockstates/medium_crystal_bud.json'
include 'assets/rivencraft/blockstates/large_crystal_bud.json'
include 'assets/rivencraft/blockstates/crystal_cluster.json'
include 'assets/rivencraft/models/block/budding_crystal.json'
include 'assets/rivencraft/models/block/small_crystal_bud.json'
include 'assets/rivencraft/models/block/medium_crystal_bud.json'
include 'assets/rivencraft/models/block/large_crystal_bud.json'
include 'assets/rivencraft/models/block/crystal_cluster.json'
include 'assets/rivencraft/models/item/budding_crystal.json'
include 'assets/rivencraft/models/item/small_crystal_bud.json'
include 'assets/rivencraft/models/item/medium_crystal_bud.json'
include 'assets/rivencraft/models/item/large_crystal_bud.json'
include 'assets/rivencraft/models/item/crystal_cluster.json'
include 'assets/rivencraft/textures/block/small_crystal_bud.png'
include 'assets/rivencraft/textures/block/medium_crystal_bud.png'
include 'assets/rivencraft/textures/block/large_crystal_bud.png'
include 'assets/rivencraft/textures/block/crystal_cluster.png'
include 'data/rivencraft/loot_table/blocks/crystal_block.json'
include 'data/minecraft/tags/block/mineable/pickaxe.json'
include 'data/minecraft/tags/item/cluster_max_harvestables.json'
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
tasks.named('runServer').configure {
standardInput = System.in
}
+10
View File
@@ -0,0 +1,10 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.caching=true
minecraft_version=26.3
minecraft_version_range=[26.3]
neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.15-alpha.1
+11
View File
@@ -0,0 +1,11 @@
pluginManagement {
repositories {
gradlePluginPortal()
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
rootProject.name = 'rivencraft-mc263-port'
@@ -0,0 +1,150 @@
package net.dnorberg.rivencraft;
import com.mojang.logging.LogUtils;
import com.mojang.serialization.Codec;
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.dnorberg.rivencraft.age.PortAgeCommands;
import net.dnorberg.rivencraft.age.AgeCatalogSync;
import net.dnorberg.rivencraft.block.BuddingCrystalBlock;
import net.dnorberg.rivencraft.block.PortBookBinderBlock;
import net.dnorberg.rivencraft.block.InkMixerBlock;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.AmethystClusterBlock;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.neoforge.registries.DeferredBlock;
import net.neoforged.neoforge.registries.DeferredItem;
import net.neoforged.neoforge.registries.DeferredRegister;
import net.neoforged.neoforge.common.extensions.IMenuTypeExtension;
import java.util.function.Supplier;
import net.neoforged.neoforge.common.NeoForge;
import org.slf4j.Logger;
/** First 26.3 registry slice; gameplay remains in the 1.21.1 target for now. */
@Mod(Rivencraft.MOD_ID)
public final class Rivencraft {
public static final String MOD_ID = "rivencraft";
public static final Logger LOGGER = LogUtils.getLogger();
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID);
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
private static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITY_TYPES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, MOD_ID);
private static final DeferredRegister<MenuType<?>> MENUS =
DeferredRegister.create(Registries.MENU, MOD_ID);
private static final DeferredRegister<RecipeSerializer<?>> RECIPE_SERIALIZERS =
DeferredRegister.create(Registries.RECIPE_SERIALIZER, MOD_ID);
private static final DeferredRegister.DataComponents COMPONENTS =
DeferredRegister.createDataComponents(Registries.DATA_COMPONENT_TYPE, MOD_ID);
public static final Supplier<DataComponentType<Identifier>> AGE_BOOK_ID = COMPONENTS.registerComponentType(
"age_book_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
public static final Supplier<DataComponentType<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
/** A present component distinguishes Link Panels from unwritten base Pages. */
public static final Supplier<DataComponentType<Boolean>> LINK_PANEL_PAGE = COMPONENTS.registerComponentType(
"link_panel_page", builder -> builder.persistent(Codec.BOOL)
.networkSynchronized(ByteBufCodecs.BOOL));
public static final Supplier<DataComponentType<Integer>> INK_REMAINING = COMPONENTS.registerComponentType(
"ink_remaining", builder -> builder.persistent(Codec.INT)
.networkSynchronized(ByteBufCodecs.VAR_INT));
public static final Supplier<DataComponentType<LinkProperties>> LINK_PROPERTIES = COMPONENTS.registerComponentType(
"link_properties", builder -> builder.persistent(LinkProperties.CODEC)
.networkSynchronized(LinkProperties.STREAM_CODEC));
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
public static final DeferredBlock<PortBookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
"book_binder", PortBookBinderBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE));
public static final DeferredItem<BlockItem> BOOK_BINDER_ITEM = ITEMS.registerSimpleBlockItem(BOOK_BINDER);
public static final DeferredBlock<InkMixerBlock> INK_MIXER = BLOCKS.registerBlock(
"ink_mixer", InkMixerBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE));
public static final DeferredItem<BlockItem> INK_MIXER_ITEM = ITEMS.registerSimpleBlockItem(INK_MIXER);
public static final Supplier<BlockEntityType<PortBookBinderBlockEntity>> BOOK_BINDER_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("book_binder", () ->
new BlockEntityType<>(PortBookBinderBlockEntity::new, BOOK_BINDER.get()));
public static final Supplier<MenuType<PortBookBinderMenu>> BOOK_BINDER_MENU =
MENUS.register("book_binder", () -> IMenuTypeExtension.create(PortBookBinderMenu::new));
public static final Supplier<BlockEntityType<InkMixerBlockEntity>> INK_MIXER_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("ink_mixer", () ->
new BlockEntityType<>(InkMixerBlockEntity::new, INK_MIXER.get()));
public static final Supplier<MenuType<InkMixerMenu>> INK_MIXER_MENU =
MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new));
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK);
public static final DeferredBlock<BuddingCrystalBlock> BUDDING_CRYSTAL = BLOCKS.registerBlock(
"budding_crystal", BuddingCrystalBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.BUDDING_AMETHYST).mapColor(MapColor.COLOR_BLUE));
public static final DeferredItem<BlockItem> BUDDING_CRYSTAL_ITEM = ITEMS.registerSimpleBlockItem(BUDDING_CRYSTAL);
public static final DeferredBlock<AmethystClusterBlock> SMALL_CRYSTAL_BUD = BLOCKS.registerBlock(
"small_crystal_bud", properties -> new AmethystClusterBlock(3.0F, 4.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.SMALL_AMETHYST_BUD));
public static final DeferredItem<BlockItem> SMALL_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(SMALL_CRYSTAL_BUD);
public static final DeferredBlock<AmethystClusterBlock> MEDIUM_CRYSTAL_BUD = BLOCKS.registerBlock(
"medium_crystal_bud", properties -> new AmethystClusterBlock(4.0F, 3.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.MEDIUM_AMETHYST_BUD));
public static final DeferredItem<BlockItem> MEDIUM_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(MEDIUM_CRYSTAL_BUD);
public static final DeferredBlock<AmethystClusterBlock> LARGE_CRYSTAL_BUD = BLOCKS.registerBlock(
"large_crystal_bud", properties -> new AmethystClusterBlock(5.0F, 3.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.LARGE_AMETHYST_BUD));
public static final DeferredItem<BlockItem> LARGE_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(LARGE_CRYSTAL_BUD);
public static final DeferredBlock<AmethystClusterBlock> CRYSTAL_CLUSTER = BLOCKS.registerBlock(
"crystal_cluster", properties -> new AmethystClusterBlock(7.0F, 3.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_CLUSTER));
public static final DeferredItem<BlockItem> CRYSTAL_CLUSTER_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_CLUSTER);
public static final DeferredItem<Item> CRYSTAL_SHARD = ITEMS.registerSimpleItem("crystal_shard");
public static final DeferredItem<PageItem> PAGE = ITEMS.registerItem("page", PageItem::new);
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
"ink_vial", InkVialItem::new, properties -> properties.stacksTo(16));
public static final DeferredItem<DescriptiveBookItem> DESCRIPTIVE_BOOK = ITEMS.registerItem(
"descriptive_book", DescriptiveBookItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<LinkingBookItem> LINKING_BOOK = ITEMS.registerItem(
"linking_book", LinkingBookItem::new, properties -> properties.stacksTo(1));
public static final Supplier<RecipeSerializer<LinkingBookRecipe>> LINKING_BOOK_RECIPE =
RECIPE_SERIALIZERS.register("linking_book", () -> LinkingBookRecipe.SERIALIZER);
public Rivencraft(IEventBus modEventBus) {
BLOCKS.register(modEventBus);
ITEMS.register(modEventBus);
BLOCK_ENTITY_TYPES.register(modEventBus);
MENUS.register(modEventBus);
COMPONENTS.register(modEventBus);
RECIPE_SERIALIZERS.register(modEventBus);
ModCreativeTabs.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
modEventBus.addListener(AgeCatalogSync::registerPayloads);
modEventBus.addListener(PortBookNetworking::registerPayloads);
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
}
}
@@ -0,0 +1,22 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Acknowledges the exact catalog version applied on the client main thread. */
public record AgeCatalogAckPayload(int version) implements CustomPacketPayload {
public static final Type<AgeCatalogAckPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog_ack"));
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogAckPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, AgeCatalogAckPayload::version, AgeCatalogAckPayload::new
);
@Override
public Type<AgeCatalogAckPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,29 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Full server level catalog, sent on login and after a runtime Age is added. */
public record AgeCatalogPayload(int version, List<Identifier> dimensions) implements CustomPacketPayload {
public static final Type<AgeCatalogPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, AgeCatalogPayload::version,
Identifier.STREAM_CODEC.apply(ByteBufCodecs.list()), AgeCatalogPayload::dimensions,
AgeCatalogPayload::new
);
public AgeCatalogPayload {
dimensions = List.copyOf(dimensions);
}
@Override
public Type<AgeCatalogPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,102 @@
package net.dnorberg.rivencraft.age;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.server.ServerStoppedEvent;
import net.neoforged.neoforge.network.PacketDistributor;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
/** Refuses runtime-Age travel until the connected client confirms its catalog. */
public final class AgeCatalogSync {
private static final Map<MinecraftServer, State> STATES = new IdentityHashMap<>();
private AgeCatalogSync() {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC);
registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) acknowledge(player, payload.version());
});
}
public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
try {
send(player);
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not send initial Age catalog to {}; travel remains blocked",
player.getName().getString(), exception);
}
}
}
public static void onLogout(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
State state = STATES.get(player.level().getServer());
if (state != null) state.acknowledged.remove(player.getUUID());
}
}
public static void onServerStopped(ServerStoppedEvent event) {
STATES.remove(event.getServer());
}
public static void onCatalogChanged(MinecraftServer server) {
State state = state(server);
state.version++;
state.acknowledged.clear();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
try {
send(player);
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not send Age catalog to {}; travel remains blocked",
player.getName().getString(), exception);
}
}
}
public static boolean ready(ServerPlayer player, Identifier target) {
MinecraftServer server = player.level().getServer();
State state = state(server);
return state.version == state.acknowledged.getOrDefault(player.getUUID(), -1)
&& server.levelKeys().stream().anyMatch(key -> key.identifier().equals(target));
}
private static void send(ServerPlayer player) {
MinecraftServer server = player.level().getServer();
State state = state(server);
state.acknowledged.remove(player.getUUID());
var dimensions = server.levelKeys().stream().map(key -> key.identifier()).sorted().toList();
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions));
}
private static void acknowledge(ServerPlayer player, int version) {
State state = state(player.level().getServer());
if (version != state.version) {
Rivencraft.LOGGER.warn("Ignoring stale Age catalog acknowledgement {} from {} (current {})",
version, player.getName().getString(), state.version);
return;
}
state.acknowledged.put(player.getUUID(), version);
Rivencraft.LOGGER.info("Age catalog {} acknowledged by {}", version, player.getName().getString());
}
private static State state(MinecraftServer server) {
return STATES.computeIfAbsent(server, ignored -> new State());
}
private static final class State {
private int version = 1;
private final Map<UUID, Integer> acknowledged = new HashMap<>();
}
}
@@ -0,0 +1,79 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Server-owned seed index for the first-party 26.3 runtime-level prototype. */
final class AgeLevelIndex extends SavedData {
private static final Codec<AgeLevelIndex> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.unboundedMap(Identifier.CODEC, Codec.LONG).fieldOf("ages").forGetter(index -> index.ages),
Identifier.CODEC.listOf().optionalFieldOf("retired", List.of())
.forGetter(index -> List.copyOf(index.retired))
).apply(instance, AgeLevelIndex::new));
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "runtime_ages"),
AgeLevelIndex::new,
CODEC
);
private final Map<Identifier, Long> ages;
private final Set<Identifier> retired;
private AgeLevelIndex() {
this.ages = new LinkedHashMap<>();
this.retired = new LinkedHashSet<>();
}
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired) {
this.ages = new LinkedHashMap<>(ages);
this.retired = new LinkedHashSet<>(retired);
}
Map<Identifier, Long> entries() {
return Map.copyOf(ages);
}
boolean contains(Identifier id) {
return ages.containsKey(id) || retired.contains(id);
}
Long seed(Identifier id) {
return ages.get(id);
}
void add(Identifier id, long seed) {
if (contains(id)) {
throw new IllegalArgumentException("Age already exists: " + id);
}
ages.put(id, seed);
setDirty();
}
void retire(Identifier id) {
if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id);
retired.add(id);
setDirty();
}
void restoreAfterFailedRetirement(Identifier id, long seed) {
if (!retired.remove(id)) throw new IllegalStateException("Age is not retired: " + id);
ages.put(id, seed);
setDirty();
}
void reseed(Identifier id, long seed) {
if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id);
ages.put(id, seed);
setDirty();
}
}
@@ -0,0 +1,221 @@
package net.dnorberg.rivencraft.age;
import com.mojang.brigadier.arguments.LongArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.minecraft.commands.Commands;
import net.minecraft.core.registries.Registries;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.levelgen.Heightmap;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
import net.neoforged.fml.ModList;
import java.util.Map;
import java.util.TreeMap;
/** Operator-only backend and synchronized-travel probes, not normal book behavior. */
public final class PortAgeCommands {
private PortAgeCommands() {
}
public static void register(RegisterCommandsEvent event) {
event.getDispatcher().register(Commands.literal("rivencraft_port")
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
.then(Commands.literal("compat")
.executes(context -> {
StringBuilder mods = new StringBuilder("Optional mods: ");
for (String id : new String[]{"jei", "terrablender", "biomesoplenty",
"sereneseasons", "glitchcore", "ruins", "create",
"ponder", "immersive_portals_core"}) {
if (mods.length() > "Optional mods: ".length()) mods.append(", ");
mods.append(id).append('=').append(ModList.get().isLoaded(id));
}
context.getSource().sendSuccess(() -> Component.literal(mods.toString()), false);
Map<String, Integer> biomeCounts = new TreeMap<>();
context.getSource().getServer().registryAccess()
.lookupOrThrow(Registries.BIOME).listElementIds()
.forEach(key -> biomeCounts.merge(key.identifier().getNamespace(), 1, Integer::sum));
context.getSource().sendSuccess(() -> Component.literal(
"Registered biomes by namespace: " + biomeCounts), false);
return 1;
}))
.then(Commands.literal("link")
.then(Commands.literal("book")
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get());
LinkBookTarget target = LinkBookTarget.at(player);
book.set(Rivencraft.LINK_BOOK_TARGET.get(), target);
book.set(DataComponents.CUSTOM_NAME, Component.literal(
"Link to " + target.dimension()));
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
context.getSource().sendSuccess(() -> Component.literal(
"Granted Linking Book bound to " + target.dimension() + " at "
+ player.blockPosition()), false);
return 1;
})))
.then(Commands.literal("age")
.then(Commands.literal("create")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
String name = StringArgumentType.getString(context, "name");
Identifier id = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "age/" + name);
long seed = LongArgumentType.getLong(context, "seed");
ServerLevel level;
try {
level = RuntimeAgeBackend.create(
context.getSource().getServer(), id, seed);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Created " + id + " with seed " + level.getSeed()), true);
return 1;
}))))
.then(Commands.literal("list")
.executes(context -> {
var ages = RuntimeAgeBackend.savedAges(context.getSource().getServer());
context.getSource().sendSuccess(() -> Component.literal(
"Saved prototype Ages: " + ages), false);
return ages.size();
}))
.then(Commands.literal("retire")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
String name = StringArgumentType.getString(context, "name");
Identifier id = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "age/" + name);
try {
RuntimeAgeBackend.retire(context.getSource().getServer(), id);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Retired " + id + "; exact Age data archived"), true);
return 1;
})))
.then(Commands.literal("unload")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
Identifier id = ageId(context);
try {
RuntimeAgeBackend.unload(context.getSource().getServer(), id);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Unloaded " + id), true);
return 1;
})))
.then(Commands.literal("reload")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
Identifier id = ageId(context);
try {
RuntimeAgeBackend.reload(context.getSource().getServer(), id);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Reloaded " + id), true);
return 1;
})))
.then(Commands.literal("reseed")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
Identifier id = ageId(context);
long seed = LongArgumentType.getLong(context, "seed");
try {
RuntimeAgeBackend.reseed(context.getSource().getServer(), id, seed);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Reseeded " + id + " to " + seed + " for future chunks"), true);
return 1;
}))))
.then(Commands.literal("enter")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
Identifier id = ageId(context);
String failure = PortAgeTravel.enter(player, id);
if (failure != null) {
context.getSource().sendFailure(Component.literal(failure));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Entered " + id + " after Age catalog acknowledgement"), false);
return 1;
})))
.then(Commands.literal("book")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
Identifier id = ageId(context);
if (!RuntimeAgeBackend.savedAges(context.getSource().getServer()).containsKey(id)) {
context.getSource().sendFailure(Component.literal("Age not active: " + id));
return 0;
}
ItemStack book = new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get());
book.set(Rivencraft.AGE_BOOK_ID.get(), id);
book.set(DataComponents.CUSTOM_NAME, Component.literal(
StringArgumentType.getString(context, "name")));
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
context.getSource().sendSuccess(() -> Component.literal(
"Granted bound Descriptive Book for " + id), false);
return 1;
})))
.then(Commands.literal("sample")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> sampleAge(context, 0))
.then(Commands.argument("chunkOffset", IntegerArgumentType.integer(0))
.executes(context -> sampleAge(context,
IntegerArgumentType.getInteger(context, "chunkOffset"))))))));
}
private static int sampleAge(com.mojang.brigadier.context.CommandContext<net.minecraft.commands.CommandSourceStack> context,
int chunkOffset) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, id));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
int fingerprint = 1;
for (int x = 0; x < 2; x++) {
for (int z = 0; z < 2; z++) {
int chunkX = chunkOffset + x * 4;
int chunkZ = chunkOffset + z * 4;
level.getChunk(chunkX, chunkZ);
fingerprint = 31 * fingerprint + level.getHeight(
Heightmap.Types.WORLD_SURFACE, chunkX * 16, chunkZ * 16);
}
}
int result = fingerprint;
context.getSource().sendSuccess(() -> Component.literal(
id + " seed=" + level.getSeed() + " chunkOffset=" + chunkOffset + " terrain=" + result), false);
return 1;
}
private static Identifier ageId(com.mojang.brigadier.context.CommandContext<net.minecraft.commands.CommandSourceStack> context) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"age/" + StringArgumentType.getString(context, "name"));
}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.age;
import java.util.Set;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
/** Shared, server-authoritative landing for the 26.3 book and operator probe. */
public final class PortAgeTravel {
private PortAgeTravel() {
}
public static String enter(ServerPlayer player, Identifier id) {
ServerLevel target = player.level().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (target == null) return "Age not loaded: " + id;
if (!AgeCatalogSync.ready(player, id)) {
return "Age catalog not acknowledged by client; remaining in source dimension";
}
BlockPos landing = findLanding(target);
if (landing == null) return "No safe test landing available; remaining in source dimension";
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
return moved ? null : "Teleportation failed; remaining in source dimension";
}
private static BlockPos findLanding(ServerLevel target) {
for (int radius = 0; radius <= 16; radius++) {
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
int y = target.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z);
BlockPos foot = new BlockPos(x, y, z);
BlockPos floor = foot.below();
if (target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP)
&& target.getBlockState(foot).getCollisionShape(target, foot).isEmpty()
&& target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty()) {
return foot;
}
}
}
}
// The operator/book probe needs a deterministic landing even when
// origin is ocean or foliage. Final book landing rules are ported later.
int y = Math.max(64, target.getHeight(Heightmap.Types.MOTION_BLOCKING, 0, 0) + 2);
if (y + 2 >= target.getMaxY()) return null;
BlockPos foot = new BlockPos(0, y, 0);
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
target.setBlock(new BlockPos(x, y - 1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
}
}
target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3);
target.setBlock(foot.above(), Blocks.AIR.defaultBlockState(), 3);
return foot;
}
}
@@ -0,0 +1,276 @@
package net.dnorberg.rivencraft.age;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.TicketStorage;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.storage.DerivedLevelData;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.world.chunk.ForcedChunkManager;
import net.neoforged.neoforge.event.level.LevelEvent;
/**
* Server-side runtime-level foundation for 26.3. Authored generators and
* dynamic client registry synchronization remain separate migration steps.
*/
public final class RuntimeAgeBackend {
private RuntimeAgeBackend() {
}
public static void onLevelLoad(LevelEvent.Load event) {
if (!(event.getLevel() instanceof ServerLevel overworld) || overworld.dimension() != Level.OVERWORLD) {
return;
}
MinecraftServer server = overworld.getServer();
AgeLevelIndex index = index(server);
for (Map.Entry<Identifier, Long> age : index.entries().entrySet()) {
try {
load(server, age.getKey(), age.getValue());
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not restore Age {}. Its saved index remains intact.", age.getKey(), exception);
}
}
}
/** Creates a server level, then commits its identity only after successful construction. */
public static ServerLevel create(MinecraftServer server, Identifier id, long seed) {
checkServerThread(server);
ageName(id);
AgeLevelIndex index = index(server);
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
ServerLevel level = load(server, id, seed);
index.add(id, seed);
// A newly bound book may be used immediately. Persist its Age identity
// before publishing the catalog so a later crash cannot leave that
// book pointing at a world omitted from the on-disk index.
server.overworld().getDataStorage().saveAndJoin();
AgeCatalogSync.onCatalogChanged(server);
return level;
}
/** Unloads an Age only when no player remains; save(true) flushes other entities. */
public static void unload(MinecraftServer server, Identifier id) {
checkServerThread(server);
requireSeed(server, id);
unloadLoaded(server, id);
AgeCatalogSync.onCatalogChanged(server);
}
public static ServerLevel reload(MinecraftServer server, Identifier id) {
checkServerThread(server);
long seed = requireSeed(server, id);
ServerLevel level = load(server, id, seed);
AgeCatalogSync.onCatalogChanged(server);
return level;
}
/** Existing saved chunks stay in place; the replacement level uses the new seed. */
public static void reseed(MinecraftServer server, Identifier id, long seed) {
checkServerThread(server);
AgeLevelIndex index = index(server);
long previousSeed = requireSeed(server, id);
if (previousSeed == seed) return;
boolean loaded = server.getLevel(dimensionKey(id)) != null;
if (loaded) unloadLoaded(server, id);
try {
if (loaded) load(server, id, seed);
index.reseed(id, seed);
server.overworld().getDataStorage().saveAndJoin();
if (loaded) AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException exception) {
if (loaded) {
try {
load(server, id, previousSeed);
AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
}
throw exception;
}
}
/** Retires one unloaded Age and moves only its own storage to a recoverable archive. */
public static void retire(MinecraftServer server, Identifier id) {
checkServerThread(server);
String name = ageName(id);
AgeLevelIndex index = index(server);
long seed = requireSeed(server, id);
boolean loaded = server.getLevel(dimensionKey(id)) != null;
ArchivePaths paths = archivePaths(server, name);
if (loaded) unloadLoaded(server, id);
// Persist the tombstone before moving files: a crash mid-archive must
// not let an old book silently regenerate this identity.
index.retire(id);
try {
server.overworld().getDataStorage().saveAndJoin();
if (Files.exists(paths.live(), LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(paths.archive().getParent());
try {
Files.move(paths.live(), paths.archive(), StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(paths.live(), paths.archive());
}
}
} catch (IOException | RuntimeException exception) {
try {
index.restoreAfterFailedRetirement(id, seed);
server.overworld().getDataStorage().saveAndJoin();
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
if (loaded) {
try {
load(server, id, seed);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
}
throw new IllegalStateException("Could not archive Age " + id, exception);
}
AgeCatalogSync.onCatalogChanged(server);
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive());
}
public static Map<Identifier, Long> savedAges(MinecraftServer server) {
checkServerThread(server);
return index(server).entries();
}
/** Includes retired IDs, which must never be silently reused by a new book. */
public static boolean isNameTaken(MinecraftServer server, Identifier id) {
checkServerThread(server);
ageName(id);
return index(server).contains(id);
}
private static AgeLevelIndex index(MinecraftServer server) {
return server.overworld().getDataStorage().computeIfAbsent(AgeLevelIndex.TYPE);
}
private static void checkServerThread(MinecraftServer server) {
if (!server.isSameThread()) throw new IllegalStateException("Runtime Age operation outside server thread");
}
private static long requireSeed(MinecraftServer server, Identifier id) {
ageName(id);
Long seed = index(server).seed(id);
if (seed == null) throw new IllegalArgumentException("Age not active: " + id);
return seed;
}
private static String ageName(Identifier id) {
if (!id.getNamespace().equals(Rivencraft.MOD_ID) || !id.getPath().startsWith("age/")) {
throw new IllegalArgumentException("Expected a rivencraft:age/* dimension ID: " + id);
}
String name = id.getPath().substring("age/".length());
if (name.isBlank() || name.contains("/") || name.equals(".") || name.equals("..")) {
throw new IllegalArgumentException("Expected a single safe Age path segment: " + id);
}
return name;
}
private static ResourceKey<Level> dimensionKey(Identifier id) {
return ResourceKey.create(Registries.DIMENSION, id);
}
private static void unloadLoaded(MinecraftServer server, Identifier id) {
ResourceKey<Level> key = dimensionKey(id);
ServerLevel level = server.getLevel(key);
if (level == null) throw new IllegalStateException("Age not loaded: " + id);
if (!level.players().isEmpty()) {
throw new IllegalStateException("Age still contains players; move them before unloading: " + id);
}
// Match the server shutdown sequence so forced tickets are persisted
// as inactive, rather than holding stale references to this level.
level.getChunkSource().deactivateTicketsOnClosing();
level.getChunkSource().tick(() -> true, false);
level.save(null, true, false);
try {
level.close();
} catch (IOException exception) {
throw new IllegalStateException("Could not close Age: " + id, exception);
}
server.levels.remove(key);
NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level));
Rivencraft.LOGGER.info("Unloaded 26.3 prototype Age {}", id);
}
private static ArchivePaths archivePaths(MinecraftServer server, String name) {
Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
Path dimensions = world.resolve("dimensions");
Path namespace = dimensions.resolve(Rivencraft.MOD_ID);
Path ageRoot = namespace.resolve("age");
Path live = ageRoot.resolve(name).normalize();
Path archiveRoot = world.getParent().resolve(world.getFileName() + "-retired-ages").normalize();
Path archive = archiveRoot.resolve(name).normalize();
if (!live.getParent().equals(ageRoot) || !archive.getParent().equals(archiveRoot)
|| Files.isSymbolicLink(world) || Files.isSymbolicLink(dimensions)
|| Files.isSymbolicLink(namespace) || Files.isSymbolicLink(ageRoot)
|| Files.isSymbolicLink(live) || Files.isSymbolicLink(archiveRoot)
|| Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException("Unsafe or existing Age archive path for " + name);
}
return new ArchivePaths(live, archive);
}
private record ArchivePaths(Path live, Path archive) {
}
private static ServerLevel load(MinecraftServer server, Identifier id, long seed) {
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
// This prototype uses vanilla Overworld terrain until issue #98 ports
// authored ChunkGenerators. LevelStem.seedOverride controls chunk random
// state as well as ServerLevel.getSeed() in NeoForge 26.3.
LevelStem overworldStem = server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM)
.getValue(LevelStem.OVERWORLD);
if (overworldStem == null) throw new IllegalStateException("Missing Overworld LevelStem");
LevelStem stem = new LevelStem(overworldStem.type(), overworldStem.generator(), OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
ServerLevel level = new ServerLevel(server, server.executor, server.storageSource,
new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()),
key, stem, server.getWorldData().isDebugWorld(), biomeZoomSeed, List.of(), false);
// Publish only after the constructor succeeds. If the load event fails,
// roll back the map and close the newly opened chunk/entity stores.
try {
server.levels.put(key, level);
level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize());
server.getPlayerList().addWorldborderListener(level);
NeoForge.EVENT_BUS.post(new LevelEvent.Load(level));
TicketStorage tickets = level.getDataStorage().get(TicketStorage.TYPE);
if (tickets != null) {
tickets.activateAllDeactivatedTickets();
ForcedChunkManager.activateAllDeactivatedTickets(level, tickets);
}
Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed);
return level;
} catch (RuntimeException exception) {
server.levels.remove(key);
try {
level.close();
} catch (IOException closeException) {
exception.addSuppressed(closeException);
}
throw exception;
}
}
}
@@ -0,0 +1,45 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.block.AmethystBlock;
import net.minecraft.world.level.block.AmethystClusterBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.BuddingAmethystBlock;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.Fluids;
/** Vanilla amethyst growth, using Rivencraft's blue crystal stages. */
public final class BuddingCrystalBlock extends AmethystBlock {
public BuddingCrystalBlock(BlockBehaviour.Properties properties) {
super(properties);
}
@Override
protected void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
if (random.nextInt(5) != 0) return;
Direction direction = Direction.values()[random.nextInt(Direction.values().length)];
BlockPos target = pos.relative(direction);
BlockState existing = level.getBlockState(target);
Block next = nextStage(existing, direction);
if (next == null) return;
level.setBlockAndUpdate(target, next.defaultBlockState()
.setValue(AmethystClusterBlock.FACING, direction)
.setValue(AmethystClusterBlock.WATERLOGGED,
existing.getFluidState().getType() == Fluids.WATER));
}
public static Block nextStage(BlockState existing, Direction direction) {
if (BuddingAmethystBlock.canClusterGrowAtState(existing)) return Rivencraft.SMALL_CRYSTAL_BUD.get();
if (!existing.hasProperty(AmethystClusterBlock.FACING)
|| existing.getValue(AmethystClusterBlock.FACING) != direction) return null;
if (existing.is(Rivencraft.SMALL_CRYSTAL_BUD)) return Rivencraft.MEDIUM_CRYSTAL_BUD.get();
if (existing.is(Rivencraft.MEDIUM_CRYSTAL_BUD)) return Rivencraft.LARGE_CRYSTAL_BUD.get();
if (existing.is(Rivencraft.LARGE_CRYSTAL_BUD)) return Rivencraft.CRYSTAL_CLUSTER.get();
return null;
}
}
@@ -0,0 +1,47 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
/** 26.3 API adaptation of the 1.21.1 Ink Mixer block. */
public final class InkMixerBlock extends BaseEntityBlock {
public InkMixerBlock(Properties properties) { super(properties); }
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new InkMixerBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof InkMixerBlockEntity mixer) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new InkMixerMenu(id, inventory, mixer),
Component.translatable("container.rivencraft.ink_mixer")),
buffer -> buffer.writeBlockPos(pos));
}
return InteractionResult.SUCCESS;
}
@Override public void playerDestroy(ServerLevel level, ServerPlayer player, BlockPos pos,
BlockState state, BlockEntity entity, ItemStack tool) {
if (entity instanceof InkMixerBlockEntity mixer) Containers.dropContents(level, pos, mixer);
super.playerDestroy(level, player, pos, state, entity, tool);
}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.phys.BlockHitResult;
public final class PortBookBinderBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public PortBookBinderBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING);
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new PortBookBinderBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof PortBookBinderBlockEntity binder) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new PortBookBinderMenu(id, inventory, binder),
Component.translatable("container.rivencraft.book_binder")),
buffer -> {
buffer.writeBlockPos(pos);
buffer.writeUtf(binder.title(), 32);
});
}
return InteractionResult.SUCCESS;
}
@Override public void playerDestroy(ServerLevel level, ServerPlayer player, BlockPos pos,
BlockState state, BlockEntity entity, ItemStack tool) {
if (entity instanceof PortBookBinderBlockEntity binder) Containers.dropContents(level, pos, binder);
super.playerDestroy(level, player, pos, state, entity, tool);
}
}
@@ -0,0 +1,176 @@
package net.dnorberg.rivencraft.block.entity;
import java.util.Random;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** Server-owned Link Panel mixer with a persistent, Legacy-style additive mixture. */
public final class InkMixerBlockEntity extends BlockEntity implements Container {
public static final int INK = 0;
public static final int PAPER = 1;
public static final int BOTTLES = 2;
public static final int SIZE = 3;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int inkRemaining;
private long nextModifierSeed = new Random().nextLong();
private final double[] modifierChances = new double[3];
private static final int[] FLAGS = {LinkProperties.DISARM,
LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.INTRA_LINKING};
private static final String[] CHANCE_KEYS = {"DisarmChance", "MomentumChance", "IntraLinkingChance"};
private static final int[] OLD_CHANCE_INDEX = {0, 2, 3};
public InkMixerBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.INK_MIXER_BLOCK_ENTITY.get(), pos, state);
}
public int inkRemaining() { return inkRemaining; }
public int modifierChancePermille(int index) {
return Math.clamp((int) Math.round(modifierChances[index] * 1000), 0, 1000);
}
/** Infuses a carried ingredient into the current ink pool; only the server calls this. */
public boolean addModifier(ItemStack carried, int amount) {
InkModifierIngredients.Ingredient ingredient = InkModifierIngredients.find(carried);
if (ingredient == null || inkRemaining == 0 || amount <= 0 || carried.getCount() < amount)
return false;
for (InkModifierIngredients.Effect effect : ingredient.effects())
for (int index = 0; index < FLAGS.length; index++)
if (FLAGS[index] == effect.flag())
modifierChances[index] = 1.0 - (1.0 - modifierChances[index])
* Math.pow(1.0 - effect.chance(), amount);
carried.shrink(amount);
setChanged();
return true;
}
public ItemStack previewLinkPanel() {
if (!canCreateLinkPanel()) return ItemStack.EMPTY;
ItemStack panel = PageItem.createLinkPanelPage();
LinkProperties properties = rollProperties(nextModifierSeed).properties();
if (properties.flags() != 0) panel.set(Rivencraft.LINK_PROPERTIES.get(), properties);
return panel;
}
private record Roll(LinkProperties properties, long nextSeed) {}
private Roll rollProperties(long seed) {
Random random = new Random(seed);
int selected = 0;
for (int index = 0; index < FLAGS.length; index++)
if (modifierChances[index] > 0 && random.nextDouble() < modifierChances[index])
selected |= FLAGS[index];
return new Roll(new LinkProperties(selected), random.nextLong());
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) {
setChanged();
if (slot == BOTTLES) refillEmptyBasin();
}
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
setChanged();
if (slot == INK || slot == BOTTLES) refillEmptyBasin();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return switch (slot) {
case INK -> stack.is(Rivencraft.INK_VIAL.get());
case PAPER -> stack.is(Items.PAPER);
default -> false;
};
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
/** No server tick: refill only when ink, bottle space, or consumption changes. */
private void refillEmptyBasin() {
if (level != null && !level.isClientSide() && inkRemaining == 0) {
int addedInk = InkVialExchange.exchange(items, INK, BOTTLES);
if (addedInk > 0) {
inkRemaining = addedInk;
setChanged();
}
}
}
public boolean canCreateLinkPanel() {
if (!items.get(PAPER).is(Items.PAPER)) return false;
return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, 1);
}
/** Commit inputs only when a virtual output is actually taken. */
public boolean consumeLinkPanelMaterials() {
if (!canCreateLinkPanel()) return false;
int cost = 1;
if (inkRemaining < cost) {
inkRemaining += InkVialExchange.exchange(items, INK, BOTTLES);
}
inkRemaining -= cost;
items.get(PAPER).shrink(1);
if (items.get(PAPER).isEmpty()) items.set(PAPER, ItemStack.EMPTY);
nextModifierSeed = rollProperties(nextModifierSeed).nextSeed();
java.util.Arrays.fill(modifierChances, 0);
setChanged();
if (inkRemaining == 0) refillEmptyBasin();
return true;
}
@Override public void setLevel(Level level) {
super.setLevel(level);
refillEmptyBasin();
}
@Override public void clearContent() {
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
java.util.Arrays.fill(modifierChances, 0);
setChanged();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
inkRemaining = Math.clamp(input.getIntOr("InkRemaining", 0), 0, InkVialItem.CAPACITY);
nextModifierSeed = input.getLongOr("NextModifierSeed", nextModifierSeed);
for (int index = 0; index < modifierChances.length; index++) {
modifierChances[index] = Math.clamp(input.getDoubleOr(CHANCE_KEYS[index],
input.getDoubleOr("ModifierChance" + OLD_CHANCE_INDEX[index], 0.0)), 0.0, 1.0);
}
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
output.putInt("InkRemaining", inkRemaining);
output.putLong("NextModifierSeed", nextModifierSeed);
for (int index = 0; index < modifierChances.length; index++)
output.putDouble(CHANCE_KEYS[index], modifierChances[index]);
}
}
@@ -0,0 +1,86 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** Persistent, server-owned inputs and output for the first 26.3 Binder slice. */
public final class PortBookBinderBlockEntity extends BlockEntity implements Container {
public static final int COVER = 0;
public static final int PANEL = 1;
public static final int OUTPUT = 2;
public static final int SIZE = 3;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private String title = "";
public PortBookBinderBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.BOOK_BINDER_BLOCK_ENTITY.get(), pos, state);
}
public String title() { return title; }
public void setTitle(String value) {
String normalized = value == null ? "" : value.strip();
if (normalized.length() > 32) return;
if (!title.equals(normalized)) {
title = normalized;
setChanged();
}
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public void clearContent() {
items.clear();
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
setChanged();
}
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) {
ItemStack removed = ContainerHelper.takeItem(items, slot);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
setChanged();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return switch (slot) {
case COVER -> stack.is(Items.LEATHER);
case PANEL -> PageItem.isLinkPanel(stack);
default -> false;
};
}
@Override public boolean stillValid(Player player) {
return level != null && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
output.putString("Title", title);
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
title = input.getStringOr("Title", "");
}
}
@@ -0,0 +1,31 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.AgeCatalogAckPayload;
import net.dnorberg.rivencraft.age.AgeCatalogPayload;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.network.event.RegisterClientPayloadHandlersEvent;
import java.util.stream.Collectors;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class AgeCatalogClient {
private AgeCatalogClient() {
}
@SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
if (!(context.listener() instanceof ClientPacketListener listener)) return;
listener.levels = payload.dimensions().stream()
.map(id -> ResourceKey.<Level>create(Registries.DIMENSION, id))
.collect(Collectors.toUnmodifiableSet());
context.reply(new AgeCatalogAckPayload(payload.version()));
});
}
}
@@ -0,0 +1,167 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
/** 1.21.1 Legacy atlas and mixer animation adapted to the 26.3 GUI API. */
public final class InkMixerScreen extends AbstractContainerScreen<InkMixerMenu> {
private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/ink_mixer_legacy.png");
private static final int BASIN_X = 88;
private static final int BASIN_Y = 49;
private static final int BASIN_RADIUS = 30;
private static final int[] PROPERTY_COLORS = {0xFFE84848, 0xFF5555FF, 0xFF55EF55};
private final int[] lastChances = new int[3];
private boolean basinClickPendingRelease;
private long lastAdditionTime;
public InkMixerScreen(InkMixerMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 181);
inventoryLabelY = 87;
}
@Override public boolean isPauseScreen() { return false; }
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractBackground(graphics, mouseX, mouseY, partialTick);
int left = (width - imageWidth) / 2;
int top = (height - imageHeight) / 2;
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, left + 54, top + 16,
179, 16, 66, 65, 256, 256);
if (menu.inkRemaining() > 0) {
graphics.fill(left + 54, top + 16, left + 120, top + 81, 0xFF101014);
renderPropertyGlyph(graphics);
}
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, left, top,
0, 0, imageWidth, imageHeight, 256, 256);
}
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
}
@Override protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
super.extractTooltip(graphics, mouseX, mouseY);
if (overBasin(mouseX, mouseY)) {
var lines = new java.util.ArrayList<Component>();
lines.add(Component.translatable("screen.rivencraft.ink_mixer.ink",
menu.inkRemaining(), InkVialItem.CAPACITY));
lines.add(Component.translatable("screen.rivencraft.ink_mixer.additive_hint"));
for (int index = 0; index < 3; index++) {
int chance = menu.modifierChancePermille(index);
if (chance > 0) lines.add(Component.translatable("screen.rivencraft.ink_mixer.chance",
switch (index) {
case 0 -> Component.translatable("item.rivencraft.link_property.disarm");
case 1 -> Component.translatable("item.rivencraft.link_property.maintain_momentum");
default -> Component.translatable("item.rivencraft.link_property.intra_linking");
}, chance / 10.0));
}
graphics.setComponentTooltipForNextFrame(font, lines, mouseX, mouseY, ItemStack.EMPTY, null);
}
}
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if ((event.button() == InputConstants.MOUSE_BUTTON_LEFT
|| event.button() == InputConstants.MOUSE_BUTTON_RIGHT)
&& overBasin(event.x(), event.y()) && !menu.getCarried().isEmpty()) {
minecraft.gameMode.handleContainerInput(menu.containerId, InkMixerMenu.MODIFIER,
event.button() == InputConstants.MOUSE_BUTTON_RIGHT ? 1 : 0,
ContainerInput.PICKUP, minecraft.player);
basinClickPendingRelease = true;
return true;
}
return super.mouseClicked(event, doubleClick);
}
@Override public boolean mouseReleased(MouseButtonEvent event) {
if (basinClickPendingRelease) {
basinClickPendingRelease = false;
return true;
}
return super.mouseReleased(event);
}
private boolean overBasin(double mouseX, double mouseY) {
double x = mouseX - leftPos - BASIN_X;
double y = mouseY - topPos - BASIN_Y;
return x * x + y * y < BASIN_RADIUS * BASIN_RADIUS;
}
/** A GUI-scale projection of Legacy's colored, rotating D'ni ink effect. */
private void renderPropertyGlyph(GuiGraphicsExtractor graphics) {
long now = System.currentTimeMillis();
int[] active = new int[3];
int count = 0;
int strongest = 0;
for (int index = 0; index < lastChances.length; index++) {
int chance = menu.modifierChancePermille(index);
if (chance > lastChances[index]) lastAdditionTime = now;
lastChances[index] = chance;
if (chance > 0) {
active[count++] = index;
strongest = Math.max(strongest, chance);
}
}
if (count == 0) {
lastAdditionTime = 0;
return;
}
double cycle = now / 1800.0;
int first = active[(int) cycle % count];
int second = active[((int) cycle + 1) % count];
double blend = (1.0 - Math.cos((cycle % 1.0) * Math.PI)) / 2.0;
int rgb = blendRgb(PROPERTY_COLORS[first], PROPERTY_COLORS[second], blend);
double pulse = Math.max(0.0, 1.0 - (now - lastAdditionTime) / 700.0);
int alpha = Math.clamp((int) (70 + strongest * 0.14 + pulse * 45), 0, 255);
int color = alpha << 24 | rgb;
int centerX = leftPos + BASIN_X;
int centerY = topPos + BASIN_Y;
double turn = now / 1200.0;
for (int spoke = 0; spoke < 4; spoke++) {
for (int step = 0; step <= 30; step++) {
double progress = step / 30.0;
double angle = turn + spoke * Math.PI / 2.0 + progress * 0.9;
double radius = 3.0 + progress * (15.0 + pulse * 3.0);
plot(graphics, centerX + Math.cos(angle) * radius,
centerY + Math.sin(angle) * radius, color);
}
}
for (int step = 0; step < 96; step++) {
double angle = step * Math.PI / 48.0 + turn * 0.3;
plot(graphics, centerX + Math.cos(angle) * 19.0,
centerY + Math.sin(angle) * 19.0, color);
if (strongest >= 990 && step % 2 == 0) {
plot(graphics, centerX + Math.cos(angle) * 23.0,
centerY + Math.sin(angle) * 23.0, color);
}
}
}
private static int blendRgb(int first, int second, double amount) {
int red = (int) (((first >> 16) & 0xFF) * (1.0 - amount)
+ ((second >> 16) & 0xFF) * amount);
int green = (int) (((first >> 8) & 0xFF) * (1.0 - amount)
+ ((second >> 8) & 0xFF) * amount);
int blue = (int) ((first & 0xFF) * (1.0 - amount) + (second & 0xFF) * amount);
return red << 16 | green << 8 | blue;
}
private static void plot(GuiGraphicsExtractor graphics, double x, double y, int color) {
int pixelX = (int) Math.round(x);
int pixelY = (int) Math.round(y);
graphics.fill(pixelX, pixelY, pixelX + 1, pixelY + 1, color);
}
}
@@ -0,0 +1,18 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class PortBookBinderClientEvents {
private PortBookBinderClientEvents() {}
@SubscribeEvent
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::new);
}
}
@@ -0,0 +1,170 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.network.AssembleBinderPayload;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.network.SetBinderTitlePayload;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
/** Legacy-sized Binder with a page strip and single-click book assembly. */
public final class PortBookBinderScreen extends AbstractContainerScreen<PortBookBinderMenu> {
private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(
"rivencraft", "textures/gui/book_binder_legacy.png");
private static final Identifier PAGE_ATLAS = Identifier.fromNamespaceAndPath(
"rivencraft", "textures/gui/notebook_pages.png");
private static final int PAGE_Y = 48;
private static final int PAGE_WIDTH = 26;
private static final int PAGE_HEIGHT = 34;
private static final int MISSING_PANEL_X = 27;
private static final int MISSING_PANEL_Y = 26;
private static final int MISSING_PANEL_SIZE = 16;
private static final ItemStack MISSING_PANEL_ICON = PageItem.createLinkPanelPage();
private EditBox titleInput;
private boolean stripClickPendingRelease;
public PortBookBinderScreen(PortBookBinderMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 181);
inventoryLabelY = 87;
}
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() {
super.init();
int left = (width - imageWidth) / 2;
int top = (height - imageHeight) / 2;
titleInput = new EditBox(font, left + 9, top + 10, 112, 12, Component.literal("Age name"));
titleInput.setBordered(false);
titleInput.setTextColor(0xFFFFFFFF);
titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setValue(menu.title());
titleInput.setHint(Component.literal("Name this Age"));
titleInput.setResponder(value -> ClientPacketDistributor.sendToServer(
new SetBinderTitlePayload(menu.containerId, value)));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
}
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractBackground(graphics, mouseX, mouseY, partialTick);
int left = (width - imageWidth) / 2;
int top = (height - imageHeight) / 2;
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, left, top, 0, 0,
imageWidth, imageHeight, 256, 256);
graphics.fill(left + 7, top + 9, left + 123, top + 23, 0xFF000000);
// The strip grows only when a page is present or the player is carrying
// a page to insert, just as the 1.21.1 Binder does.
boolean panelPresent = !menu.getSlot(1).getItem().isEmpty();
if (panelPresent || PageItem.isLinkPanel(menu.getCarried())) {
int x = left + 16;
graphics.blit(RenderPipelines.GUI_TEXTURED, PAGE_ATLAS,
x, top + PAGE_Y, 156, 0, PAGE_WIDTH, PAGE_HEIGHT, 256, 256);
if (!panelPresent)
graphics.fill(x, top + PAGE_Y, x + PAGE_WIDTH, top + PAGE_Y + PAGE_HEIGHT,
0xAA000000);
else graphics.fill(x + 5, top + PAGE_Y + 6, x + 22, top + PAGE_Y + 17,
0xFF000000);
}
if (titleInput == null || titleInput.getValue().isBlank()) {
graphics.fill(left + 7, top + 9, left + 123, top + 10, 0xFFFF3030);
graphics.fill(left + 7, top + 22, left + 123, top + 23, 0xFFFF3030);
}
if (!panelPresent) {
graphics.fakeItem(MISSING_PANEL_ICON, left + MISSING_PANEL_X, top + MISSING_PANEL_Y);
float phase = (System.currentTimeMillis() % 4000L) / 2000.0F;
float fade = phase > 1.0F ? 2.0F - phase : phase;
int alpha = 40 + (int) (125 * fade);
graphics.fill(left + MISSING_PANEL_X, top + MISSING_PANEL_Y,
left + MISSING_PANEL_X + MISSING_PANEL_SIZE,
top + MISSING_PANEL_Y + MISSING_PANEL_SIZE,
(alpha << 24) | 0x00B02020);
}
if (menu.getSlot(2).getItem().isEmpty() && !menu.getSlot(0).getItem().isEmpty()
&& !menu.getSlot(1).getItem().isEmpty() && titleInput != null
&& !titleInput.getValue().isBlank()) {
graphics.fakeItem(new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), left + 152, top + 27);
}
}
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
}
@Override protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
super.extractTooltip(graphics, mouseX, mouseY);
int x = mouseX - leftPos;
int y = mouseY - topPos;
if (x >= MISSING_PANEL_X && x < MISSING_PANEL_X + MISSING_PANEL_SIZE
&& y >= MISSING_PANEL_Y && y < MISSING_PANEL_Y + MISSING_PANEL_SIZE
&& menu.getSlot(1).getItem().isEmpty()) {
graphics.setTooltipForNextFrame(font,
Component.translatable("screen.rivencraft.book_binder.missing_panel"), mouseX, mouseY);
} else if (x >= 16 && x < 16 + PAGE_WIDTH && y >= PAGE_Y && y < PAGE_Y + PAGE_HEIGHT
&& !menu.getSlot(1).getItem().isEmpty()) {
graphics.setTooltipForNextFrame(font, menu.getSlot(1).getItem(), mouseX, mouseY);
}
}
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
int left = (width - imageWidth) / 2;
int top = (height - imageHeight) / 2;
if ((event.button() == InputConstants.MOUSE_BUTTON_LEFT
|| event.button() == InputConstants.MOUSE_BUTTON_RIGHT)
&& event.x() >= left + 16 && event.x() < left + 16 + PAGE_WIDTH
&& event.y() >= top + PAGE_Y && event.y() < top + PAGE_Y + PAGE_HEIGHT) {
boolean hasPanel = !menu.getSlot(1).getItem().isEmpty();
if (hasPanel && menu.getCarried().isEmpty()
|| !hasPanel && PageItem.isLinkPanel(menu.getCarried())) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
PortBookBinderMenu.STRIP_CLICK_PANEL);
stripClickPendingRelease = true;
return true;
}
}
if (event.button() == InputConstants.MOUSE_BUTTON_LEFT
&& event.x() >= left + 151 && event.x() < left + 170
&& event.y() >= top + 26 && event.y() < top + 45
&& menu.getSlot(2).getItem().isEmpty() && menu.getCarried().isEmpty()) {
String name = titleInput.getValue().strip();
if (!name.isEmpty()) ClientPacketDistributor.sendToServer(new AssembleBinderPayload(name));
return true;
}
return super.mouseClicked(event, doubleClick);
}
@Override public boolean mouseReleased(MouseButtonEvent event) {
if (stripClickPendingRelease) {
stripClickPendingRelease = false;
return true;
}
return super.mouseReleased(event);
}
@Override public boolean keyPressed(KeyEvent event) {
if (titleInput != null && titleInput.isFocused()) {
if (event.isEscape()) {
setFocused(null);
titleInput.setFocused(false);
} else {
titleInput.keyPressed(event);
}
// The character event inserts text. Never forward a focused key
// to the container's inventory/hotbar shortcuts.
return true;
}
return super.keyPressed(event);
}
}
@@ -0,0 +1,29 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.client.Minecraft;
import net.minecraft.world.InteractionResult;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
/** Opens screens only on the physical client, including integrated singleplayer. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class PortBookInteractionHandler {
private PortBookInteractionHandler() {
}
@SubscribeEvent
public static void onRightClickItem(PlayerInteractEvent.RightClickItem event) {
if (!event.getEntity().level().isClientSide()) return;
boolean linking = event.getItemStack().is(Rivencraft.LINKING_BOOK.get());
boolean descriptive = event.getItemStack().is(Rivencraft.DESCRIPTIVE_BOOK.get());
if (!linking && !descriptive) return;
event.setCanceled(true);
event.setCancellationResult(InteractionResult.SUCCESS);
Minecraft.getInstance().setScreenAndShow(linking
? new PortLinkingBookScreen(event.getHand(), event.getItemStack().copy())
: new PortDescriptiveBookScreen(event.getHand(), event.getItemStack().copy()));
}
}
@@ -0,0 +1,116 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.network.BindDescriptiveBookPayload;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
/** Temporary 26.3 descriptive-book interaction; authored pages and live previews remain unported. */
public final class PortDescriptiveBookScreen extends Screen {
private static final int WIDTH = 327;
private static final int HEIGHT = 199;
private static final int TEXT_COLOR = 0xFF3B281B;
private static final Identifier COVER = texture("bookui_cover.png");
private static final Identifier LEFT_PAGE = texture("notebook_pages.png");
private static final Identifier RIGHT_PANEL = texture("bookui_pager.png");
private final InteractionHand hand;
private final boolean bound;
private EditBox titleInput;
public PortDescriptiveBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName());
this.hand = hand;
bound = book.has(Rivencraft.AGE_BOOK_ID.get());
}
@Override
protected void init() {
if (bound) return;
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
Component.literal("Age name"));
titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.literal("Name this Age"));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.literal("Bind"), button -> bind())
.bounds(left + 40, top + 100, 53, 20).build());
addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose())
.bounds(left + 97, top + 100, 53, 20).build());
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
if (bound) {
graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D);
graphics.text(font, Component.literal("Age preview unavailable"), left + 181, top + 47,
0xFFE9DFCB, false);
graphics.text(font, Component.literal("Click to enter"), left + 205, top + 65,
0xFFE9DFCB, false);
} else {
graphics.text(font, Component.literal("Bind a prototype Age"), left + 183, top + 39,
TEXT_COLOR, false);
}
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
if (event.x() >= left + 173 && event.x() < left + 305
&& event.y() >= top + 20 && event.y() < top + 103) {
ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(hand == InteractionHand.OFF_HAND));
onClose();
return true;
}
}
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean isPauseScreen() {
return false;
}
private void bind() {
if (titleInput == null) return;
String name = titleInput.getValue().strip();
if (name.isEmpty()) return;
ClientPacketDistributor.sendToServer(new BindDescriptiveBookPayload(hand == InteractionHand.OFF_HAND, name));
onClose();
}
private static Identifier texture(String filename) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename);
}
private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y,
int width, int height, int u, int v) {
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256);
}
}
@@ -0,0 +1,114 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.network.BindLinkingBookPayload;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.network.UseLinkingBookPayload;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
/** Minimal 26.3 book interaction using the Legacy artwork; live preview is not ported yet. */
public final class PortLinkingBookScreen extends Screen {
private static final int WIDTH = 327;
private static final int HEIGHT = 199;
private static final int TEXT_COLOR = 0xFF3B281B;
private static final Identifier COVER = texture("bookui_cover.png");
private static final Identifier LEFT_PAGE = texture("notebook_pages.png");
private static final Identifier RIGHT_PANEL = texture("bookui_pager.png");
private final InteractionHand hand;
private final boolean bound;
private EditBox titleInput;
public PortLinkingBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName());
this.hand = hand;
bound = book.has(Rivencraft.LINK_BOOK_TARGET.get());
}
@Override
protected void init() {
if (bound) return;
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
Component.literal("Linking Book name"));
titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.literal("Name this link"));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.literal("Bind"), button -> bind())
.bounds(left + 40, top + 100, 53, 20).build());
addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose())
.bounds(left + 97, top + 100, 53, 20).build());
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
if (bound) blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
if (bound) {
graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D);
graphics.text(font, Component.literal("Destination preview"), left + 185, top + 47, 0xFFE9DFCB, false);
graphics.text(font, Component.literal("Click to link"), left + 205, top + 65, 0xFFE9DFCB, false);
} else {
graphics.text(font, Component.literal("Name this Linking Book"), left + 183, top + 39,
TEXT_COLOR, false);
}
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
if (event.x() >= left + 173 && event.x() < left + 305
&& event.y() >= top + 20 && event.y() < top + 103) {
ClientPacketDistributor.sendToServer(new UseLinkingBookPayload(hand == InteractionHand.OFF_HAND));
onClose();
return true;
}
}
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean isPauseScreen() {
return false;
}
private void bind() {
if (titleInput == null) return;
String name = titleInput.getValue().strip();
if (name.isEmpty()) return;
ClientPacketDistributor.sendToServer(new BindLinkingBookPayload(hand == InteractionHand.OFF_HAND, name));
onClose();
}
private static Identifier texture(String filename) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename);
}
private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y,
int width, int height, int u, int v) {
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256);
}
}
@@ -0,0 +1,40 @@
package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
import net.minecraft.world.level.Level;
/** Descriptive Book shell for the 26.3 prototype Age backend. */
public final class DescriptiveBookItem extends Item {
public DescriptiveBookItem(Properties properties) {
super(properties);
}
@Override
public boolean isFoil(ItemStack stack) {
return stack.has(Rivencraft.AGE_BOOK_ID.get()) || super.isFoil(stack);
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
// The client opens the GUI. Binding and travel are validated on the
// server through dedicated packets; item use cannot bypass them.
return InteractionResult.SUCCESS;
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
tooltip.accept(Component.literal(id == null ? "Unbound (26.3 port preview)" : "Age: " + id));
}
}
@@ -0,0 +1,76 @@
package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.chat.Component;
import net.minecraft.util.Prediction;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
import net.minecraft.world.level.Level;
/** A stackable 16-use vial; using it with paper in the other hand prepares a Link Panel. */
public final class InkVialItem extends Item {
public static final int CAPACITY = 16;
// Match the 1.21.1 default; port the configurable cost with the full configuration system.
public static final int LINK_PANEL_COST = 2;
public InkVialItem(Properties properties) {
super(properties);
}
public static int remaining(ItemStack vial) {
return vial.getOrDefault(Rivencraft.INK_REMAINING.get(), CAPACITY);
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
InteractionHand paperHand = hand == InteractionHand.MAIN_HAND
? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
ItemStack vial = player.getItemInHand(hand);
ItemStack paper = player.getItemInHand(paperHand);
int ink = remaining(vial);
if (!vial.is(Rivencraft.INK_VIAL.get()) || !paper.is(Items.PAPER)
|| ink < LINK_PANEL_COST || ink > CAPACITY) {
return InteractionResult.PASS;
}
if (level.isClientSide()) return InteractionResult.SUCCESS;
ItemStack panel = PageItem.createLinkPanelPage();
int newInk = ink - LINK_PANEL_COST;
ItemStack spentVial = newInk == 0
? new ItemStack(Items.GLASS_BOTTLE)
: new ItemStack(Rivencraft.INK_VIAL.get());
if (newInk > 0) spentVial.set(Rivencraft.INK_REMAINING.get(), newInk);
if (paper.getCount() == 1) {
player.setItemInHand(paperHand, panel);
} else {
paper.shrink(1);
giveOrDrop(player, panel);
}
if (vial.getCount() == 1) {
player.setItemInHand(hand, spentVial);
} else {
vial.shrink(1);
giveOrDrop(player, spentVial);
}
return InteractionResult.SUCCESS;
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("item.rivencraft.ink_vial.remaining", remaining(stack), CAPACITY));
tooltip.accept(Component.translatable("item.rivencraft.ink_vial.usage"));
}
private static void giveOrDrop(Player player, ItemStack stack) {
if (!player.getInventory().add(stack)) player.drop(stack, false, Prediction.SERVER_ONLY);
}
}
@@ -0,0 +1,40 @@
package net.dnorberg.rivencraft.item;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
/** Persistent destination captured when a 26.3 prototype Linking Book is bound. */
public record LinkBookTarget(Identifier dimension, double x, double y, double z, float yaw, float pitch) {
public static final Codec<LinkBookTarget> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("dimension").forGetter(LinkBookTarget::dimension),
Codec.DOUBLE.fieldOf("x").forGetter(LinkBookTarget::x),
Codec.DOUBLE.fieldOf("y").forGetter(LinkBookTarget::y),
Codec.DOUBLE.fieldOf("z").forGetter(LinkBookTarget::z),
Codec.FLOAT.fieldOf("yaw").forGetter(LinkBookTarget::yaw),
Codec.FLOAT.fieldOf("pitch").forGetter(LinkBookTarget::pitch)
).apply(instance, LinkBookTarget::new));
public static final StreamCodec<RegistryFriendlyByteBuf, LinkBookTarget> STREAM_CODEC = StreamCodec.composite(
Identifier.STREAM_CODEC, LinkBookTarget::dimension,
ByteBufCodecs.DOUBLE, LinkBookTarget::x,
ByteBufCodecs.DOUBLE, LinkBookTarget::y,
ByteBufCodecs.DOUBLE, LinkBookTarget::z,
ByteBufCodecs.FLOAT, LinkBookTarget::yaw,
ByteBufCodecs.FLOAT, LinkBookTarget::pitch,
LinkBookTarget::new);
public static LinkBookTarget at(ServerPlayer player) {
return new LinkBookTarget(player.level().dimension().identifier(),
player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot());
}
public boolean valid() {
return Double.isFinite(x) && Double.isFinite(y) && Double.isFinite(z)
&& Float.isFinite(yaw) && Float.isFinite(pitch)
&& Math.abs(x) <= 30_000_000 && Math.abs(z) <= 30_000_000;
}
}
@@ -0,0 +1,29 @@
package net.dnorberg.rivencraft.item;
import com.mojang.serialization.Codec;
import io.netty.buffer.ByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** The three Ink Mixer modifiers currently supported by the 26.3 port. */
public record LinkProperties(int flags) {
public static final int DISARM = 1;
public static final int MAINTAIN_MOMENTUM = 4;
public static final int INTRA_LINKING = 8;
public static final int SUPPORTED = DISARM | MAINTAIN_MOMENTUM | INTRA_LINKING;
public static final LinkProperties NONE = new LinkProperties(0);
public static final Codec<LinkProperties> CODEC = Codec.INT.xmap(LinkProperties::new, LinkProperties::flags);
public static final StreamCodec<ByteBuf, LinkProperties> STREAM_CODEC = StreamCodec.of(
(buffer, value) -> ByteBufCodecs.VAR_INT.encode(buffer, value.flags()),
buffer -> new LinkProperties(ByteBufCodecs.VAR_INT.decode(buffer)));
public LinkProperties {
if (flags < 0 || (flags & ~SUPPORTED) != 0) {
throw new IllegalArgumentException("Unsupported Link Panel flags: " + flags);
}
}
public boolean has(int flag) {
return (flags & flag) != 0;
}
}
@@ -0,0 +1,119 @@
package net.dnorberg.rivencraft.item;
import java.util.Set;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.AgeCatalogSync;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
import net.minecraft.world.level.Level;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.phys.Vec3;
/** Handheld Linking Book; destination binding and travel are server-authoritative packets. */
public final class LinkingBookItem extends Item {
public LinkingBookItem(Properties properties) {
super(properties);
}
@Override
public boolean isFoil(ItemStack stack) {
return stack.has(Rivencraft.LINK_BOOK_TARGET.get()) || super.isFoil(stack);
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
// Client-side interaction opens the book screen. A direct server-side
// item-use packet must never bypass the screen's validated payloads.
return InteractionResult.SUCCESS;
}
public static String travel(ServerPlayer player, ItemStack book) {
LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (destination == null) return "Unbound Linking Book (26.3 port preview)";
if (!destination.valid()) return "Invalid Linking Book destination";
if (player.level().dimension().identifier().equals(destination.dimension())
&& !properties.has(LinkProperties.INTRA_LINKING)) {
return "Same-dimension linking requires an Intra-Linking page";
}
ServerLevel target = player.level().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, destination.dimension()));
if (target == null) return "Destination dimension is unavailable: " + destination.dimension();
if (!AgeCatalogSync.ready(player, destination.dimension())) {
return "Destination catalog not acknowledged by client";
}
BlockPos feet = BlockPos.containing(destination.x(), destination.y(), destination.z());
if (feet.getY() < target.getMinY() || feet.getY() + 1 >= target.getMaxY()
|| !target.getWorldBorder().isWithinBounds(feet)) {
return "Destination is outside the world bounds";
}
// Test the player's actual arrival volume: checking whole block shapes
// would incorrectly reject links bound while standing on a slab.
if (!target.noCollision(player, player.getBoundingBox().move(
destination.x() - player.getX(), destination.y() - player.getY(),
destination.z() - player.getZ()))) {
return "Linked destination is obstructed";
}
ServerLevel source = player.level();
Vec3 departure = player.position();
Vec3 incoming = player.getDeltaMovement();
boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(),
Set.of(), destination.yaw(), destination.pitch(), true);
if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw());
return moved ? null : "Teleportation failed";
}
/** Link Panel effects are shared by Linking and Descriptive Books. */
public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties,
ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw) {
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) {
double speed = Math.hypot(incoming.x, incoming.z);
double yaw = Math.toRadians(destinationYaw);
player.setDeltaMovement(speed * Math.cos(yaw), incoming.y + 0.2,
speed * Math.sin(yaw));
} else {
player.setDeltaMovement(Vec3.ZERO);
}
if (properties.has(LinkProperties.DISARM)) {
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) {
ItemStack carried = player.getInventory().getItem(slot);
if (carried.isEmpty()) continue;
player.getInventory().setItem(slot, ItemStack.EMPTY);
ItemEntity dropped = new ItemEntity(source, departure.x, departure.y, departure.z,
carried.copy());
dropped.setDefaultPickUpDelay();
source.addFreshEntity(dropped);
}
player.getInventory().setChanged();
player.containerMenu.broadcastChanges();
}
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
LinkBookTarget target = stack.get(Rivencraft.LINK_BOOK_TARGET.get());
tooltip.accept(Component.literal(target == null ? "Unbound (26.3 port preview)"
: "Destination: " + target.dimension() + " (" + (int) target.x() + ", "
+ (int) target.y() + ", " + (int) target.z() + ")"));
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (properties.has(LinkProperties.DISARM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm"));
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.maintain_momentum"));
if (properties.has(LinkProperties.INTRA_LINKING))
tooltip.accept(Component.translatable("item.rivencraft.link_property.intra_linking"));
}
}
@@ -0,0 +1,55 @@
package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
/** One Page item with a persistent Link Panel variant; symbol writing is not ported yet. */
public final class PageItem extends Item {
public PageItem(Properties properties) {
super(properties);
}
public static ItemStack createLinkPanelPage() {
return createLinkPanelPage(0);
}
public static ItemStack createLinkPanelPage(int flags) {
ItemStack stack = new ItemStack(Rivencraft.PAGE.get());
stack.set(Rivencraft.LINK_PANEL_PAGE.get(), true);
if (flags != 0) stack.set(Rivencraft.LINK_PROPERTIES.get(), new LinkProperties(flags));
return stack;
}
public static boolean isLinkPanel(ItemStack stack) {
return stack.is(Rivencraft.PAGE.get()) && stack.getOrDefault(Rivencraft.LINK_PANEL_PAGE.get(), false);
}
@Override
public Component getName(ItemStack stack) {
Component customName = stack.get(DataComponents.CUSTOM_NAME);
if (customName != null) return customName;
return Component.translatable(isLinkPanel(stack)
? "item.rivencraft.page.link_panel" : "item.rivencraft.page.blank");
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (properties.has(LinkProperties.DISARM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm"));
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.maintain_momentum"));
if (properties.has(LinkProperties.INTRA_LINKING))
tooltip.accept(Component.translatable("item.rivencraft.link_property.intra_linking"));
tooltip.accept(Component.translatable(isLinkPanel(stack)
? "item.rivencraft.page.link_panel.port_status"
: "item.rivencraft.page.blank.port_status"));
}
}
@@ -0,0 +1,158 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.inventory.DataSlot;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Server-owned virtual output; taking it spends paper and the configured ink cost once. */
public final class InkMixerMenu extends AbstractContainerMenu {
public static final int MODIFIER = InkMixerBlockEntity.SIZE;
public static final int RESULT = MODIFIER + 1;
private static final int PLAYER_START = RESULT + 1;
private final InkMixerBlockEntity mixer;
private final Container inputs;
private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide;
private int inkRemaining;
private final int[] modifierChances = new int[3];
public InkMixerMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof InkMixerBlockEntity found ? found : null);
}
public InkMixerMenu(int id, Inventory inventory, InkMixerBlockEntity mixer) {
super(Rivencraft.INK_MIXER_MENU.get(), id);
this.mixer = mixer;
inputs = mixer == null ? new SimpleContainer(InkMixerBlockEntity.SIZE) : mixer;
clientSide = inventory.player.level().isClientSide();
addDataSlot(new DataSlot() {
@Override public int get() { return clientSide ? inkRemaining : mixer.inkRemaining(); }
@Override public void set(int value) { inkRemaining = value; }
});
for (int index = 0; index < modifierChances.length; index++) {
final int chanceIndex = index;
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide ? modifierChances[chanceIndex] : mixer.modifierChancePermille(chanceIndex);
}
@Override public void set(int value) { modifierChances[chanceIndex] = value; }
});
}
addSlot(new MixerSlot(InkMixerBlockEntity.INK, 8, 27));
addSlot(new MixerSlot(InkMixerBlockEntity.PAPER, 8, 48));
addSlot(new MixerSlot(InkMixerBlockEntity.BOTTLES, 152, 27));
addSlot(new ModifierSlot(79, 40));
addSlot(new ResultSlot(152, 48));
for (int row = 0; row < 3; row++)
for (int column = 0; column < 9; column++)
addSlot(new Slot(inventory, column + row * 9 + 9, 8 + column * 18, 99 + row * 18));
for (int column = 0; column < 9; column++)
addSlot(new Slot(inventory, column, 8 + column * 18, 157));
refreshResult();
}
public int inkRemaining() { return inkRemaining; }
public int modifierChancePermille(int index) { return modifierChances[index]; }
@Override public void clicked(int slotId, int button, ContainerInput clickType, Player player) {
if (slotId == MODIFIER) {
if (!clientSide && stillValid(player) && clickType == ContainerInput.PICKUP
&& (button == 0 || button == 1)) {
ItemStack carried = getCarried();
int amount = button == 1 ? 1 : carried.getCount();
if (mixer.addModifier(carried, amount)) {
setCarried(carried);
refreshResult();
broadcastChanges();
}
}
return;
}
super.clicked(slotId, button, clickType, player);
// In 26.3, recompute the virtual result in the same packet that changes
// paper or ink. Waiting for a later menu update can require a second click.
if (!clientSide) refreshResult();
}
@Override public boolean stillValid(Player player) {
return mixer != null && mixer.stillValid(player);
}
@Override public void broadcastChanges() {
refreshResult();
super.broadcastChanges();
}
private void refreshResult() {
if (clientSide) return;
ItemStack preview = mixer == null ? ItemStack.EMPTY : mixer.previewLinkPanel();
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
if (index == RESULT) {
refreshResult();
if (!mixer.canCreateLinkPanel()) return ItemStack.EMPTY;
ItemStack output = result.getItem(0).copy();
ItemStack remainder = output.copy();
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
return ItemStack.EMPTY;
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
refreshResult();
return output;
}
Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack source = slot.getItem();
ItemStack original = source.copy();
boolean moved;
if (index < MODIFIER) moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
else if (source.is(Rivencraft.INK_VIAL.get()))
moved = moveItemStackTo(source, InkMixerBlockEntity.INK, InkMixerBlockEntity.INK + 1, false);
else if (source.is(Items.PAPER))
moved = moveItemStackTo(source, InkMixerBlockEntity.PAPER, InkMixerBlockEntity.PAPER + 1, false);
else moved = false;
if (!moved) return ItemStack.EMPTY;
if (source.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
else slot.setChanged();
refreshResult();
return original;
}
private final class MixerSlot extends Slot {
MixerSlot(int index, int x, int y) { super(inputs, index, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return inputs.canPlaceItem(getContainerSlot(), stack); }
}
private static final class ModifierSlot extends Slot {
ModifierSlot(int x, int y) { super(new SimpleContainer(1), 0, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) { return false; }
}
private final class ResultSlot extends Slot {
ResultSlot(int x, int y) { super(result, 0, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) {
return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && mixer.consumeLinkPanelMaterials()) {
refreshResult();
}
super.onTake(player, stack);
}
}
}
@@ -0,0 +1,129 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Binder inputs persist in the block; assembling places the finished book on the cursor. */
public final class PortBookBinderMenu extends AbstractContainerMenu {
public static final int STRIP_CLICK_PANEL = 3000;
private final Container binder;
private final PortBookBinderBlockEntity blockEntity;
private final String initialTitle;
public PortBookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof PortBookBinderBlockEntity found ? found : null, data.readUtf(32));
}
public PortBookBinderMenu(int id, Inventory inventory, PortBookBinderBlockEntity blockEntity) {
this(id, inventory, blockEntity, blockEntity == null ? "" : blockEntity.title());
}
private PortBookBinderMenu(int id, Inventory inventory, PortBookBinderBlockEntity blockEntity, String title) {
super(Rivencraft.BOOK_BINDER_MENU.get(), id);
this.blockEntity = blockEntity;
initialTitle = title;
binder = blockEntity == null ? new SimpleContainer(PortBookBinderBlockEntity.SIZE) : blockEntity;
addSlot(new Slot(binder, PortBookBinderBlockEntity.COVER, 8, 27) {
@Override public boolean mayPlace(ItemStack stack) { return stack.is(Items.LEATHER); }
});
addSlot(new Slot(binder, PortBookBinderBlockEntity.PANEL, -1000, -1000) {
@Override public boolean mayPlace(ItemStack stack) { return PageItem.isLinkPanel(stack); }
@Override public int getMaxStackSize() { return 1; }
});
addSlot(new Slot(binder, PortBookBinderBlockEntity.OUTPUT, 152, 27) {
@Override public boolean mayPlace(ItemStack stack) { return false; }
});
addStandardInventorySlots(inventory, 8, 99);
}
@Override public boolean stillValid(Player player) {
return blockEntity != null && blockEntity.stillValid(player);
}
public String title() { return initialTitle; }
@Override public boolean clickMenuButton(Player player, int id) {
if (id != STRIP_CLICK_PANEL || !stillValid(player)) return false;
ItemStack carried = getCarried();
if (carried.isEmpty()) {
ItemStack panel = binder.removeItem(PortBookBinderBlockEntity.PANEL, 1);
if (panel.isEmpty()) return false;
setCarried(panel);
} else {
if (!PageItem.isLinkPanel(carried)
|| !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty()) return false;
binder.setItem(PortBookBinderBlockEntity.PANEL, carried.copyWithCount(1));
ItemStack remainder = carried.copy();
remainder.shrink(1);
setCarried(remainder);
}
broadcastChanges();
return true;
}
public void setTitle(ServerPlayer player, String value) {
if (stillValid(player) && value != null && value.length() <= PortBookNetworking.MAX_BOOK_TITLE_LENGTH)
blockEntity.setTitle(value);
}
public void assemble(ServerPlayer player, String title) {
if (!stillValid(player) || title.isBlank() || title.length() > PortBookNetworking.MAX_BOOK_TITLE_LENGTH
|| !binder.getItem(PortBookBinderBlockEntity.OUTPUT).isEmpty()
|| !getCarried().isEmpty()) return;
ItemStack cover = binder.getItem(PortBookBinderBlockEntity.COVER);
ItemStack panel = binder.getItem(PortBookBinderBlockEntity.PANEL);
if (!cover.is(Items.LEATHER) || !PageItem.isLinkPanel(panel)) return;
blockEntity.setTitle(title);
ItemStack book = new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get());
var id = PortBookNetworking.createAge(player, title);
if (id == null) return;
book.set(Rivencraft.AGE_BOOK_ID.get(), id);
book.set(DataComponents.CUSTOM_NAME, Component.literal(title));
var modifiers = panel.get(Rivencraft.LINK_PROPERTIES.get());
if (modifiers != null) book.set(Rivencraft.LINK_PROPERTIES.get(), modifiers);
cover.shrink(1);
panel.shrink(1);
binder.setItem(PortBookBinderBlockEntity.COVER, cover);
binder.setItem(PortBookBinderBlockEntity.PANEL, panel);
setCarried(book);
broadcastChanges();
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack stack = slot.getItem();
ItemStack original = stack.copy();
boolean moved;
if (index < PortBookBinderBlockEntity.SIZE) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.SIZE, slots.size(), true);
} else if (stack.is(Items.LEATHER)) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.COVER,
PortBookBinderBlockEntity.COVER + 1, false);
} else if (PageItem.isLinkPanel(stack)) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.PANEL,
PortBookBinderBlockEntity.PANEL + 1, false);
} else return ItemStack.EMPTY;
if (!moved) return ItemStack.EMPTY;
if (stack.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
else slot.setChanged();
slot.onTake(player, stack);
return original;
}
}
@@ -0,0 +1,17 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record AssembleBinderPayload(String title) implements CustomPacketPayload {
public static final Type<AssembleBinderPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "assemble_binder"));
public static final StreamCodec<RegistryFriendlyByteBuf, AssembleBinderPayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.stringUtf8(32), AssembleBinderPayload::title,
AssembleBinderPayload::new);
@Override public Type<AssembleBinderPayload> type() { return TYPE; }
}
@@ -0,0 +1,22 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record BindDescriptiveBookPayload(boolean offhand, String title) implements CustomPacketPayload {
public static final Type<BindDescriptiveBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_descriptive_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, BindDescriptiveBookPayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.BOOL, BindDescriptiveBookPayload::offhand,
ByteBufCodecs.stringUtf8(32), BindDescriptiveBookPayload::title,
BindDescriptiveBookPayload::new);
@Override
public Type<BindDescriptiveBookPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,22 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record BindLinkingBookPayload(boolean offhand, String title) implements CustomPacketPayload {
public static final Type<BindLinkingBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_linking_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, BindLinkingBookPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, BindLinkingBookPayload::offhand,
ByteBufCodecs.stringUtf8(32), BindLinkingBookPayload::title,
BindLinkingBookPayload::new);
@Override
public Type<BindLinkingBookPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,141 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortAgeTravel;
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.phys.Vec3;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
import java.util.Locale;
/** Server-authoritative handheld Linking Book binding and travel. */
public final class PortBookNetworking {
public static final int MAX_BOOK_TITLE_LENGTH = 32;
private PortBookNetworking() {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) bind(player, payload);
});
registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) use(player, payload);
});
registrar.playToServer(BindDescriptiveBookPayload.TYPE, BindDescriptiveBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) bindAge(player, payload);
});
registrar.playToServer(UseDescriptiveBookPayload.TYPE, UseDescriptiveBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) enterAge(player, payload);
});
registrar.playToServer(AssembleBinderPayload.TYPE, AssembleBinderPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof PortBookBinderMenu menu) {
menu.assemble(player, payload.title().strip());
}
});
registrar.playToServer(SetBinderTitlePayload.TYPE, SetBinderTitlePayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof PortBookBinderMenu menu
&& menu.containerId == payload.containerId()) {
menu.setTitle(player, payload.title());
}
});
}
private static void bind(ServerPlayer player, BindLinkingBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.LINKING_BOOK.get()) || stack.has(Rivencraft.LINK_BOOK_TARGET.get())) return;
String title = payload.title().strip();
if (title.isEmpty() || title.length() > MAX_BOOK_TITLE_LENGTH) return;
stack.set(Rivencraft.LINK_BOOK_TARGET.get(), LinkBookTarget.at(player));
stack.set(DataComponents.CUSTOM_NAME, Component.literal(title));
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN,
SoundSource.PLAYERS, 1.0F, 1.0F);
player.sendOverlayMessage(Component.literal("Bound Linking Book: " + title));
player.containerMenu.broadcastChanges();
}
private static void use(ServerPlayer player, UseLinkingBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.LINKING_BOOK.get())) return;
String failure = LinkingBookItem.travel(player, stack);
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
}
private static void bindAge(ServerPlayer player, BindDescriptiveBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()) || stack.has(Rivencraft.AGE_BOOK_ID.get())) return;
String title = payload.title().strip();
if (title.isEmpty() || title.length() > MAX_BOOK_TITLE_LENGTH) return;
Identifier id = createAge(player, title);
if (id == null) return;
stack.set(Rivencraft.AGE_BOOK_ID.get(), id);
stack.set(DataComponents.CUSTOM_NAME, Component.literal(title));
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN,
SoundSource.PLAYERS, 1.0F, 1.0F);
player.sendOverlayMessage(Component.literal("Bound Descriptive Book: " + title));
player.containerMenu.broadcastChanges();
}
/** Shared name resolution and Age creation for handheld books and the Binder. */
public static Identifier createAge(ServerPlayer player, String title) {
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
String base = title.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "_")
.replaceAll("^[_\\.-]+|[_\\.-]+$", "");
if (base.isEmpty()) base = "age";
var server = player.level().getServer();
String name = base;
int suffix = 2;
Identifier id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
while (RuntimeAgeBackend.isNameTaken(server, id)) {
name = base + "_" + suffix++;
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
}
try {
RuntimeAgeBackend.create(server, id, player.getRandom().nextLong());
} catch (IllegalArgumentException | IllegalStateException exception) {
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
return null;
}
return id;
}
private static void enterAge(ServerPlayer player, UseDescriptiveBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.DESCRIPTIVE_BOOK.get())) return;
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
if (id == null) return;
ServerLevel source = player.level();
Vec3 departure = player.position();
Vec3 incoming = player.getDeltaMovement();
String failure = PortAgeTravel.enter(player, id);
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
else LinkingBookItem.applySuccessfulLink(player,
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
source, departure, incoming, player.getYRot());
}
private static InteractionHand hand(boolean offhand) {
return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
}
}
@@ -0,0 +1,18 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record SetBinderTitlePayload(int containerId, String title) implements CustomPacketPayload {
public static final Type<SetBinderTitlePayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_binder_title"));
public static final StreamCodec<RegistryFriendlyByteBuf, SetBinderTitlePayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetBinderTitlePayload::containerId,
ByteBufCodecs.stringUtf8(32), SetBinderTitlePayload::title,
SetBinderTitlePayload::new);
@Override public Type<SetBinderTitlePayload> type() { return TYPE; }
}
@@ -0,0 +1,21 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record UseDescriptiveBookPayload(boolean offhand) implements CustomPacketPayload {
public static final Type<UseDescriptiveBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_descriptive_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, UseDescriptiveBookPayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.BOOL, UseDescriptiveBookPayload::offhand,
UseDescriptiveBookPayload::new);
@Override
public Type<UseDescriptiveBookPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,20 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record UseLinkingBookPayload(boolean offhand) implements CustomPacketPayload {
public static final Type<UseLinkingBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_linking_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, UseLinkingBookPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, UseLinkingBookPayload::offhand, UseLinkingBookPayload::new);
@Override
public Type<UseLinkingBookPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,58 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.CreativeModeTab;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredRegister;
/** Only items already ported to 26.3 appear here. */
public final class ModCreativeTabs {
private static final DeferredRegister<CreativeModeTab> TABS =
DeferredRegister.create(Registries.CREATIVE_MODE_TAB, Rivencraft.MOD_ID);
static {
TABS.register("rivencraft", () -> CreativeModeTab.builder()
.title(Component.translatable("itemGroup.rivencraft"))
.icon(() -> Rivencraft.DESCRIPTIVE_BOOK.get().getDefaultInstance())
.displayItems((parameters, output) -> {
output.accept(Rivencraft.DESCRIPTIVE_BOOK.get());
output.accept(Rivencraft.LINKING_BOOK.get());
output.accept(Rivencraft.CRYSTAL_BLOCK_ITEM.get());
output.accept(Rivencraft.BUDDING_CRYSTAL_ITEM.get());
output.accept(Rivencraft.SMALL_CRYSTAL_BUD_ITEM.get());
output.accept(Rivencraft.MEDIUM_CRYSTAL_BUD_ITEM.get());
output.accept(Rivencraft.LARGE_CRYSTAL_BUD_ITEM.get());
output.accept(Rivencraft.CRYSTAL_CLUSTER_ITEM.get());
output.accept(Rivencraft.CRYSTAL_SHARD.get());
output.accept(Rivencraft.INK_VIAL.get());
output.accept(Rivencraft.BOOK_BINDER_ITEM.get());
output.accept(Rivencraft.INK_MIXER_ITEM.get());
})
.build());
TABS.register("rivencraft_link_pages", () -> CreativeModeTab.builder()
.title(Component.translatable("itemGroup.rivencraft.pages.link"))
.icon(PageItem::createLinkPanelPage)
.displayItems((parameters, output) -> {
int[] flags = {LinkProperties.DISARM, LinkProperties.MAINTAIN_MOMENTUM,
LinkProperties.INTRA_LINKING};
for (int combination = 0; combination < 8; combination++) {
int selected = 0;
for (int bit = 0; bit < flags.length; bit++)
if ((combination & (1 << bit)) != 0) selected |= flags[bit];
output.accept(PageItem.createLinkPanelPage(selected));
}
})
.build());
}
private ModCreativeTabs() {
}
public static void register(IEventBus modEventBus) {
TABS.register(modEventBus);
}
}
@@ -0,0 +1,31 @@
package net.dnorberg.rivencraft.writing;
import java.util.List;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Built-in Legacy ink additives, carried forward from the 1.21.1 mixer. */
public final class InkModifierIngredients {
public record Effect(int flag, double chance) {}
public record Ingredient(ItemStack item, List<Effect> effects) {}
private static final List<Ingredient> BUILT_IN = List.of(
new Ingredient(new ItemStack(Items.GUNPOWDER), List.of(new Effect(LinkProperties.DISARM, 0.20))),
new Ingredient(new ItemStack(Items.MUSHROOM_STEW), List.of(new Effect(LinkProperties.DISARM, 0.05))),
new Ingredient(new ItemStack(Items.EXPERIENCE_BOTTLE), List.of(new Effect(LinkProperties.INTRA_LINKING, 0.15))),
new Ingredient(new ItemStack(Items.ENDER_PEARL), List.of(
new Effect(LinkProperties.INTRA_LINKING, 0.15),
new Effect(LinkProperties.DISARM, 0.15))),
new Ingredient(new ItemStack(Items.FEATHER), List.of(new Effect(LinkProperties.MAINTAIN_MOMENTUM, 0.15))),
new Ingredient(new ItemStack(Items.FIRE_CHARGE), List.of(new Effect(LinkProperties.DISARM, 0.25)))
);
private InkModifierIngredients() {}
public static List<Ingredient> builtIn() { return BUILT_IN; }
public static Ingredient find(ItemStack stack) {
return BUILT_IN.stream().filter(ingredient -> stack.is(ingredient.item().getItem())).findFirst().orElse(null);
}
}
@@ -0,0 +1,43 @@
package net.dnorberg.rivencraft.writing;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.minecraft.core.NonNullList;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Shared, server-side vial-to-meter exchange from the 1.21.1 Desk and Mixer. */
public final class InkVialExchange {
private InkVialExchange() {}
public static boolean canPay(NonNullList<ItemStack> items, int inkSlot, int bottleSlot,
int storedInk, int cost) {
return storedInk >= cost || canExchange(items, inkSlot, bottleSlot)
&& storedInk + vialInk(items.get(inkSlot)) >= cost;
}
/** Returns added ink; zero means no inputs were changed. */
public static int exchange(NonNullList<ItemStack> items, int inkSlot, int bottleSlot) {
if (!canExchange(items, inkSlot, bottleSlot)) return 0;
ItemStack vial = items.get(inkSlot);
int addedInk = vialInk(vial);
vial.shrink(1);
if (vial.isEmpty()) items.set(inkSlot, ItemStack.EMPTY);
ItemStack bottles = items.get(bottleSlot);
if (bottles.isEmpty()) items.set(bottleSlot, new ItemStack(Items.GLASS_BOTTLE));
else bottles.grow(1);
return addedInk;
}
private static boolean canExchange(NonNullList<ItemStack> items, int inkSlot, int bottleSlot) {
ItemStack vial = items.get(inkSlot);
ItemStack bottles = items.get(bottleSlot);
return vial.is(Rivencraft.INK_VIAL.get()) && vialInk(vial) > 0
&& (bottles.isEmpty() || bottles.is(Items.GLASS_BOTTLE)
&& bottles.getCount() < bottles.getMaxStackSize());
}
private static int vialInk(ItemStack vial) {
return Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
}
}
Loaded 100 of 585 files, more files were not shown because too many files have changed in this diff. Show more