Compare commits

..
Author SHA1 Message Date
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
185 changed files with 5061 additions and 466 deletions

No files matched your search

+93 -259
View File
@@ -1,283 +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, writing-system, and Age-grammar milestones are complete.
The writing system uses a data-pack symbol catalog and persistent
ordered-writing model. Linking Books and Descriptive Books create persistent
Ages from either authored supported symbols or seeded random completion.
Milestone 6 instability work is in progress: Ages persist frozen instability
scores and provider plans, and the bounded runtime implements status effects,
colored decay, lightning, explosions, meteors, burning, and erosion. The
[Milestone 5 acceptance record](docs/MILESTONE_5_ACCEPTANCE.md) lists the
supported grammar, optional integrations, and limits; the
[instability runtime policy](docs/INSTABILITY_RUNTIME.md) records the current
effects and modern safety boundaries.
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; taking one Link Panel
uses the full vial. No server tick is needed. The earlier paper-in-one-hand
method still uses the configurable link-panel ink cost. Ink Mixer
additives can roll Disarm, Maintain Momentum, and Intra Linking onto a Link
Panel. Descriptive Books retain Disarm and Momentum; Intra Linking is only
meaningful for same-dimension travel with a Linking Book.
TerraBlender 1.21.1 integration is optional: registered modded biomes receive
Biome pages, with cave and Nether biomes in a separate Cave Biome creative tab.
TerraBlender Overworld/Nether regions extend compatible Age
biome pools. See [the compatibility matrix](docs/TERRABLENDER_COMPATIBILITY.md)
for supported versions and in-game verification.
Serene Seasons 1.21.1 can maintain separate season progress in an Age dimension.
Rivencraft automatically whitelists loaded Ages without a restart when Serene is installed;
season pages can instead opt an Age out or hold it in one season.
See [the optional compatibility guide](docs/SERENE_SEASONS_COMPATIBILITY.md)
for the supported manual configuration and current limits; Rivencraft does not
require Serene Seasons or GlitchCore.
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.
The Link Panel and individual symbol Pages, including registered vanilla biome
pages, appear as separately searchable JEI ingredients.
Creative inventory separates Link Pages and each symbol category into their own
tabs, including distinct Biome and Biome Generation tabs. Blank Pages are hidden
from creative inventory and JEI.
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.
Four Crystal Shards craft one Crystal Block; eight Crystal Blocks around an
empty crafting-grid center make one Book Receptacle. Picking up a shard unlocks
the block recipe, and picking up a block unlocks the Receptacle recipe. The
existing Ink Vial recipe uses two black dyes, a glass bottle, and a water
bucket; the empty bucket stays in the crafting grid. These JSON recipes appear
in JEI when it is installed and can be replaced by a data pack at
`data/rivencraft/recipe/<recipe_name>.json`. To test a replacement, use a fresh
world or `/reload`, then check the result in a crafting table and vanilla
Crafter. A vanilla Lectern converts to a Rivencraft Book Stand when a Rivencraft
book or page is placed on it; this in-world interaction has no JEI guide.
With Create installed, Crystal Blocks and Book Receptacles have an optional Ponder scene
showing crystal-frame construction, book activation, and a parrot crossing the portal. Use the Ponder shortcut
while hovering either item; Rivencraft does not require Create to run.
Crystal frames may stand vertically or lie flat: a 3×3 Crystal Block ring with
an empty center forms a horizontal portal when a Book Receptacle is attached to
the top or underside of one of its blocks. Insert a bound Linking or Descriptive
Book to activate it; the book remains visible on the receptacle. A Linking Book aimed at its
own dimension still opens a visual portal, but travel requires Intra Linking.
## Requirements and optional integrations
Sky writing includes eight color pages and a Static Sky page. Multiple sky-color
pages blend their colors, tinting the Age sky, book preview, and the fallback
surface of portals linked there. Multiple celestial-color pages blend for the
next written sun or moon. An Age may have no suns or moons at all;
Static Sky animates the sky with television-like noise. Sun and Moon pages add
independent bodies; preceding color, size, angle, period, phase, and direction
pages configure the next body. The third and each subsequent sun add increasing
instability. Newly generated page-free Ages also choose a deterministic sky,
color, and celestial arrangement from their Age seed; older saved Ages retain
their previous sky.
Install the same gameplay mods on the client and server:
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.
- 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.
The completed book receives its Age identity when assembled. With only a Link
Panel, it describes a randomly generated Age. Authored writing is compiled once
into a versioned, server-owned Age plan. The plan freezes the Age seed, resolved
symbol meanings, page order, and runtime profile, so later data-pack changes do
not reinterpret an existing Age. An authored terrain page selects its runtime
profile. Built-in pages cover Standard, Amplified, Cave, Island, Flat, and Void;
authored biome, environment, and supported feature pages also affect new Ages.
The
writing is also stored on the book; the Descriptive Book screen's turned pages
show ordered symbols and unresolved IDs. 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 with their runtime-rendered
pictograms. Link-panel-only books use Rivencraft glyphs for their generated
Age description.
Rivencraft works without any of these optional integrations:
## Linking Book
| 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. |
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.
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.
Successful book travel plays Mystcraft Legacy's original linking sound at both
the departure and arrival locations.
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.
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.
## Saves, settings, and current limits
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.
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.
## Descriptive Book
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.
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.
## Project and development
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.
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.
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.
Linking creates a bound Return Link to the exact source position and orientation
according to the configured Return Link policy.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `DISABLED` (default), `ON_CREATION`, or `ALWAYS`.
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.264 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).
+5
View File
@@ -245,6 +245,11 @@ 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()) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

+11 -10
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
@@ -101,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.
+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.
+4
View File
@@ -1,5 +1,9 @@
# GameTest suites
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.
+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).
+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.
+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.
+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.
+429
View File
@@ -0,0 +1,429 @@
# 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.
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.
## 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.
+7 -2
View File
@@ -1,5 +1,9 @@
# 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
@@ -62,8 +66,9 @@ acceptance issue and all required implementation issues are complete.
**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. Start with the
[Milestone 7 Legacy audit](LEGACY_M7_AUDIT.md).
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
+5 -2
View File
@@ -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,
+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.
+4 -3
View File
@@ -32,9 +32,10 @@ The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Le
| `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. |
| `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. |
+1 -1
View File
@@ -36,7 +36,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.6.8.7
mod_version=1.0.1.1
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=pHEgQQUE
+53
View File
@@ -0,0 +1,53 @@
# 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, 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 Book Binder and symbol-writing systems
are still unported. The player verified the Ink Vial interaction in game.
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 Book Binder remains the next larger step for
assembling Descriptive Books from authored pages.
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.
+115
View File
@@ -0,0 +1,115 @@
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/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.9-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,113 @@
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.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkBookTarget;
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.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 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<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 DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
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);
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,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,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,77 @@
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;
/** 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, LinkBookTarget destination) {
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())) {
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";
}
boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(),
Set.of(), destination.yaw(), destination.pitch(), true);
return moved ? null : "Teleportation failed";
}
@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() + ")"));
}
}
@@ -0,0 +1,43 @@
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() {
ItemStack stack = new ItemStack(Rivencraft.PAGE.get());
stack.set(Rivencraft.LINK_PANEL_PAGE.get(), true);
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) {
tooltip.accept(Component.translatable(isLinkPanel(stack)
? "item.rivencraft.page.link_panel.port_status"
: "item.rivencraft.page.blank.port_status"));
}
}
@@ -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,108 @@
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.LinkingBookItem;
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.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);
});
}
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.get(Rivencraft.LINK_BOOK_TARGET.get()));
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;
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;
}
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();
}
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;
String failure = PortAgeTravel.enter(player, id);
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
}
private static InteractionHand hand(boolean offhand) {
return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
}
}
@@ -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,46 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
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());
})
.build());
TABS.register("rivencraft_link_pages", () -> CreativeModeTab.builder()
.title(Component.translatable("itemGroup.rivencraft.pages.link"))
.icon(PageItem::createLinkPanelPage)
.displayItems((parameters, output) -> output.accept(PageItem.createLinkPanelPage()))
.build());
}
private ModCreativeTabs() {
}
public static void register(IEventBus modEventBus) {
TABS.register(modEventBus);
}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.crafting.CraftingInput;
import net.minecraft.world.item.crafting.CustomRecipe;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.level.Level;
/** Legacy's shapeless leather + Link Panel recipe; never accepts a blank Page. */
public final class LinkingBookRecipe extends CustomRecipe {
private static final LinkingBookRecipe INSTANCE = new LinkingBookRecipe();
public static final MapCodec<LinkingBookRecipe> MAP_CODEC = MapCodec.unit(INSTANCE);
public static final StreamCodec<RegistryFriendlyByteBuf, LinkingBookRecipe> STREAM_CODEC = StreamCodec.unit(INSTANCE);
public static final RecipeSerializer<LinkingBookRecipe> SERIALIZER = new RecipeSerializer<>(MAP_CODEC, STREAM_CODEC);
private LinkingBookRecipe() {
}
private static ItemStack findPanel(CraftingInput input) {
if (input.ingredientCount() != 2) return ItemStack.EMPTY;
ItemStack panel = ItemStack.EMPTY;
boolean leather = false;
for (ItemStack stack : input.items()) {
if (stack.isEmpty()) continue;
if (PageItem.isLinkPanel(stack) && panel.isEmpty()) {
panel = stack;
} else if (stack.is(Items.LEATHER) && !leather) {
leather = true;
} else {
return ItemStack.EMPTY;
}
}
return leather ? panel : ItemStack.EMPTY;
}
@Override
public boolean matches(CraftingInput input, Level level) {
return !findPanel(input).isEmpty();
}
@Override
public ItemStack assemble(CraftingInput input) {
ItemStack panel = findPanel(input);
if (panel.isEmpty()) return ItemStack.EMPTY;
ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get());
if (panel.has(DataComponents.CUSTOM_NAME)) {
book.set(DataComponents.CUSTOM_NAME, panel.get(DataComponents.CUSTOM_NAME));
}
return book;
}
@Override
public RecipeSerializer<LinkingBookRecipe> getSerializer() {
return SERIALIZER;
}
}
@@ -0,0 +1,8 @@
# The 26.3 server has no public runtime-Level insertion API. Keep the
# access surface restricted to the three fields used by RuntimeAgeBackend.
public net.minecraft.server.MinecraftServer levels
public net.minecraft.server.MinecraftServer storageSource
public net.minecraft.server.MinecraftServer executor
# Minecraft 26.3 may expose an immutable level catalog. Replace the field
# atomically when the server announces new runtime Ages instead of mutating it.
public net.minecraft.client.multiplayer.ClientPacketListener levels
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/budding_crystal"}}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:block/crystal_block"
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/crystal_cluster"}}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/crystal_shard"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/descriptive_book"
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/ink_vial"}}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/large_crystal_bud"}}
@@ -0,0 +1,15 @@
{
"model": {
"type": "minecraft:condition",
"property": "minecraft:has_component",
"component": "rivencraft:link_book_target",
"on_true": {
"type": "minecraft:model",
"model": "rivencraft:item/linking_book_bound"
},
"on_false": {
"type": "minecraft:model",
"model": "rivencraft:item/linking_book_unwritten"
}
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/medium_crystal_bud"}}
@@ -0,0 +1,9 @@
{
"model": {
"type": "minecraft:condition",
"property": "minecraft:has_component",
"component": "rivencraft:link_panel_page",
"on_true": {"type": "minecraft:model", "model": "rivencraft:item/page_link_panel"},
"on_false": {"type": "minecraft:model", "model": "rivencraft:item/page"}
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/small_crystal_bud"}}
@@ -0,0 +1,21 @@
{
"item.rivencraft.crystal_shard": "Crystal Shard",
"item.rivencraft.descriptive_book": "Descriptive Book",
"item.rivencraft.linking_book": "Linking Book",
"item.rivencraft.page": "Page",
"item.rivencraft.page.blank": "Blank Page",
"item.rivencraft.page.link_panel": "Link Panel Page",
"item.rivencraft.ink_vial": "Ink Vial",
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
"item.rivencraft.ink_vial.usage": "Use with paper in the other hand to prepare a Link Panel.",
"item.rivencraft.page.blank.port_status": "Base for future symbol writing; not yet usable in this port.",
"item.rivencraft.page.link_panel.port_status": "Prepared for book binding; the Book Binder is not yet ported.",
"block.rivencraft.crystal_block": "Crystal Block",
"block.rivencraft.budding_crystal": "Budding Crystal",
"block.rivencraft.small_crystal_bud": "Small Crystal Bud",
"block.rivencraft.medium_crystal_bud": "Medium Crystal Bud",
"block.rivencraft.large_crystal_bud": "Large Crystal Bud",
"block.rivencraft.crystal_cluster": "Crystal Cluster",
"itemGroup.rivencraft": "Rivencraft",
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages"
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/linking_book_unwritten"
}
}
@@ -0,0 +1 @@
{"parent":"minecraft:item/generated","textures":{"layer0":"rivencraft:item/page"}}
@@ -0,0 +1 @@
{"type":"minecraft:block","random_sequence":"rivencraft:blocks/budding_crystal"}
@@ -0,0 +1,23 @@
{
"type": "minecraft:block",
"pools": [{
"entries": [{
"type": "minecraft:alternatives",
"children": [
{"type": "minecraft:item", "condition": "minecraft:tool/can_silk_touch", "name": "rivencraft:crystal_cluster"},
{"type": "minecraft:alternatives", "children": [
{"type": "minecraft:item", "condition": {"type": "minecraft:match_tool", "predicate": {"items": "#minecraft:cluster_max_harvestables"}}, "modifier": [
{"type": "minecraft:set_count", "count": 4},
{"type": "minecraft:apply_bonus", "enchantment": "minecraft:fortune", "formula": "minecraft:ore_drops"}
], "name": "rivencraft:crystal_shard"},
{"type": "minecraft:item", "modifier": [
{"type": "minecraft:set_count", "count": 2},
{"type": "minecraft:explosion_decay"}
], "name": "rivencraft:crystal_shard"}
]}
]
}],
"rolls": 1
}],
"random_sequence": "rivencraft:blocks/crystal_cluster"
}
@@ -0,0 +1 @@
{"type":"minecraft:block","pools":[{"condition":"minecraft:tool/can_silk_touch","entries":[{"type":"minecraft:item","name":"rivencraft:large_crystal_bud"}],"rolls":1}],"random_sequence":"rivencraft:blocks/large_crystal_bud"}
@@ -0,0 +1 @@
{"type":"minecraft:block","pools":[{"condition":"minecraft:tool/can_silk_touch","entries":[{"type":"minecraft:item","name":"rivencraft:medium_crystal_bud"}],"rolls":1}],"random_sequence":"rivencraft:blocks/medium_crystal_bud"}
@@ -0,0 +1 @@
{"type":"minecraft:block","pools":[{"condition":"minecraft:tool/can_silk_touch","entries":[{"type":"minecraft:item","name":"rivencraft:small_crystal_bud"}],"rolls":1}],"random_sequence":"rivencraft:blocks/small_crystal_bud"}
@@ -0,0 +1,12 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"pattern": ["SS", "SS"],
"key": {
"S": "rivencraft:crystal_shard"
},
"result": {
"id": "rivencraft:crystal_block",
"count": 1
}
}
@@ -0,0 +1,11 @@
{
"type": "minecraft:crafting_shapeless",
"category": "misc",
"ingredients": [
"minecraft:black_dye",
"minecraft:black_dye",
"minecraft:glass_bottle",
"minecraft:water_bucket"
],
"result": {"id": "rivencraft:ink_vial", "count": 1}
}
@@ -0,0 +1 @@
{"type":"rivencraft:linking_book"}
@@ -0,0 +1,28 @@
license="${mod_license}"
issueTrackerURL="https://git.paddedcell.org/dnorberg/rivencraft/issues"
[[mods]]
modId="${mod_id}"
version="${mod_version}"
displayName="${mod_name} 26.3 Alpha"
displayURL="https://git.paddedcell.org/dnorberg/rivencraft"
authors="dnorberg and contributors"
description='''
Experimental Minecraft 26.3 alpha. Provides a runtime Age prototype and basic
Linking Book travel. Most Rivencraft gameplay has not yet been ported. Use a
test world and keep backups; this alpha is not compatible with 1.21.1 worlds.
'''
[[dependencies.${mod_id}]]
modId="neoforge"
type="required"
versionRange="[${neo_version},)"
ordering="NONE"
side="BOTH"
[[dependencies.${mod_id}]]
modId="minecraft"
type="required"
versionRange="${minecraft_version_range}"
ordering="NONE"
side="BOTH"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Disposable, headless smoke check for the incomplete 26.3 runtime Age backend.
set -euo pipefail
repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
smoke_dir=$(mktemp -d "${TMPDIR:-/tmp}/rivencraft-263-smoke.XXXXXX")
server_dir="$smoke_dir/server"
server_pid=
input_fd=
cleanup() {
if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then
if [[ -n "$input_fd" ]]; then printf 'stop\n' >&"$input_fd" || true; fi
wait "$server_pid" 2>/dev/null || true
fi
if [[ -n "$input_fd" ]]; then exec {input_fd}>&-; fi
rm -rf -- "$smoke_dir"
}
trap cleanup EXIT
mkdir -p "$server_dir"
printf 'eula=true\n' > "$server_dir/eula.txt"
printf 'server-port=0\nonline-mode=false\nwhite-list=false\n' > "$server_dir/server.properties"
wait_for_log() {
local log_file=$1 pattern=$2 seconds=$3
local tries=$((seconds * 4))
for ((i = 0; i < tries; i++)); do
if grep -Fq -- "$pattern" "$log_file" 2>/dev/null; then return 0; fi
if ! kill -0 "$server_pid" 2>/dev/null; then
printf 'Server exited before: %s\n' "$pattern" >&2
tail -80 "$log_file" >&2
return 1
fi
sleep 0.25
done
printf 'Timed out waiting for: %s\n' "$pattern" >&2
tail -80 "$log_file" >&2
return 1
}
start_server() {
local phase=$1
log_file="$smoke_dir/$phase.log"
fifo="$smoke_dir/$phase.stdin"
mkfifo "$fifo"
exec {input_fd}<>"$fifo"
"$repo_dir/gradlew" -p "$repo_dir/ports/mc263" runServer \
-PserverRunDir="$server_dir" --no-daemon < "$fifo" > "$log_file" 2>&1 &
server_pid=$!
wait_for_log "$log_file" 'Done (' 120
}
stop_server() {
printf 'stop\n' >&"$input_fd"
wait "$server_pid"
server_pid=
exec {input_fd}>&-
rm -- "$fifo"
}
send_and_wait() {
local command=$1 result=$2
printf '%s\n' "$command" >&"$input_fd"
wait_for_log "$log_file" "$result" 60
}
start_server first
send_and_wait 'rivencraft_port compat' 'Registered biomes by namespace:'
send_and_wait 'rivencraft_port age create smoke_alpha 123456789' 'Created rivencraft:age/smoke_alpha with seed 123456789'
send_and_wait 'rivencraft_port age create smoke_beta 987654321' 'Created rivencraft:age/smoke_beta with seed 987654321'
# The index must reach disk while the server is still running. A graceful
# shutdown would hide the interrupted-save regression this checks.
index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/runtime_ages.dat"
[[ -s "$index_file" ]] || {
printf 'Age index was not persisted before shutdown: %s\n' "$index_file" >&2
exit 1
}
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_alpha'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_beta'
send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain='
alpha_before=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
beta_before=$(grep -F 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
if [[ "$alpha_before" == "$beta_before" ]]; then
printf 'Different Age seeds produced the same terrain probe: %s\n' "$alpha_before" >&2
exit 1
fi
stop_server
start_server restart
send_and_wait 'rivencraft_port age list' 'smoke_alpha'
send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain='
alpha_after=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
beta_after=$(grep -F 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
[[ "$alpha_before" == "$alpha_after" && "$beta_before" == "$beta_after" ]] || {
printf 'Age terrain changed across restart: %s/%s -> %s/%s\n' \
"$alpha_before" "$beta_before" "$alpha_after" "$beta_after" >&2
exit 1
}
stop_server
printf '26.3 dedicated-server smoke passed: two distinct Ages persisted across restart.\n'
@@ -20,6 +20,7 @@ import net.dnorberg.rivencraft.network.ModNetworking;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeWeatherService;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
@@ -43,6 +44,7 @@ public final class Rivencraft {
public Rivencraft(IEventBus modEventBus, ModContainer modContainer) {
AgeDimensionService.register();
RuinsCompatibility.register();
AgeStatusEffectProviders.register();
AgeDecayProviders.register();
AgeHazardProviders.register();
@@ -82,6 +84,8 @@ public final class Rivencraft {
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.server.ServerStoppingEvent event) ->
RuinsCompatibility.onServerStopping());
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerTickPost);
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerStopping);
@@ -36,6 +36,7 @@ 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.DirectionProperty;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
@@ -44,10 +45,11 @@ import net.minecraft.world.phys.shapes.VoxelShape;
public final class BookReceptacleBlock extends BaseEntityBlock {
public static final MapCodec<BookReceptacleBlock> CODEC = simpleCodec(BookReceptacleBlock::new);
public static final DirectionProperty FACING = BlockStateProperties.FACING;
public static final BooleanProperty HAS_BOOK = BlockStateProperties.HAS_BOOK;
public BookReceptacleBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(HAS_BOOK, false));
}
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@@ -64,7 +66,7 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
};
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(FACING);
builder.add(FACING, HAS_BOOK);
}
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new BookReceptacleBlockEntity(pos, state);
@@ -153,7 +155,7 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
if (!state.is(replacement.getBlock()) && level instanceof ServerLevel server) {
CrystalPortalNetwork.shutdown(server, pos);
if (level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder) {
ItemStack book = holder.takeBook();
ItemStack book = holder.takeBookOnBreak();
if (!book.isEmpty()) level.addFreshEntity(new ItemEntity(level, pos.getX()+0.5, pos.getY()+0.5,
pos.getZ()+0.5, book));
}
@@ -2,7 +2,9 @@ package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.AgeEnvironmentCompiler;
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.minecraft.nbt.NbtOps;
import net.minecraft.core.BlockPos;
@@ -52,18 +54,36 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
}
public ItemStack takeBook() {
return removeBook(true);
}
/** The block is already being replaced, so do not write its state back into the world. */
public ItemStack takeBookOnBreak() {
return removeBook(false);
}
private ItemStack removeBook(boolean notify) {
ItemStack result = book;
book = ItemStack.EMPTY;
destination = null;
skyColor = 0xFFFFFF;
sync();
if (notify) sync();
else setChanged();
return result;
}
private void sync() {
setChanged();
if (level != null && !level.isClientSide)
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
if (level instanceof ServerLevel server) {
BlockState state = server.getBlockState(worldPosition);
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get())) return;
boolean hasBook = !book.isEmpty();
if (state.getValue(BookReceptacleBlock.HAS_BOOK) != hasBook) {
state = state.setValue(BookReceptacleBlock.HAS_BOOK, hasBook);
server.setBlock(worldPosition, state, 2);
}
server.sendBlockUpdated(worldPosition, state, state, 2);
}
}
@Override
@@ -98,6 +118,10 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state,
BookReceptacleBlockEntity holder) {
if (!(world instanceof ServerLevel level)) return;
// Reconcile receptacles saved before HAS_BOOK existed, including an
// occupied receptacle restored from an older pre-release world.
boolean hasBook = !holder.book.isEmpty();
if (state.getValue(BookReceptacleBlock.HAS_BOOK) != hasBook) holder.sync();
if (holder.book.isEmpty() || (!holder.restoreView && level.getGameTime() % 200 != 0)) return;
// Startup maintenance must never turn a saved portal off while its
// chunks and block entities are still arriving. Structural edits are
@@ -8,6 +8,7 @@ import java.util.Random;
import java.util.Set;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.core.Holder;
@@ -34,6 +35,12 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
public static AgeBiomeSelection compile(WritingGrammar.Result writing, AgeTraits traits,
AgeRuntimeProfile profile, long seed, RegistryAccess registries) {
return compile(writing, traits, profile, seed, registries, RandomPagePolicy.UNRESTRICTED);
}
public static AgeBiomeSelection compile(WritingGrammar.Result writing, AgeTraits traits,
AgeRuntimeProfile profile, long seed, RegistryAccess registries,
RandomPagePolicy policy) {
List<ResourceLocation> authored = writing.symbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.BIOME)
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList();
@@ -67,10 +74,26 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
+ " or a TerraBlender Nether region");
}
}
List<ResourceLocation> availableBiomes = eligible(registries, profile);
if (availableBiomes.isEmpty()) throw new IllegalArgumentException("No eligible biomes for terrain " + profile.noiseSettings());
List<ResourceLocation> terrainBiomes = eligible(registries, profile);
if (terrainBiomes.isEmpty()) throw new IllegalArgumentException("No eligible biomes for terrain " + profile.noiseSettings());
List<ResourceLocation> availableBiomes = distribution == AgeTraits.BiomeDistribution.NATIVE
? terrainBiomes : new ArrayList<>(policy.eligible(terrainBiomes, BiomeSymbolPages::symbolId));
if (distribution != AgeTraits.BiomeDistribution.NATIVE
&& new LinkedHashSet<>(availableBiomes).size() < minimum(distribution)) {
ResourceLocation preferred = profile.terrain() == AgeRuntimeProfile.Terrain.VOID
? ResourceLocation.withDefaultNamespace("the_void")
: cave ? ResourceLocation.withDefaultNamespace("deep_dark")
: ResourceLocation.withDefaultNamespace("plains");
if (terrainBiomes.contains(preferred) && !availableBiomes.contains(preferred))
availableBiomes.add(preferred);
for (ResourceLocation fallback : terrainBiomes) {
if (new LinkedHashSet<>(availableBiomes).size() >= minimum(distribution)) break;
if (!availableBiomes.contains(fallback)) availableBiomes.add(fallback);
}
}
if (distribution == AgeTraits.BiomeDistribution.NATIVE) {
if (!authored.isEmpty()) throw new IllegalArgumentException("Native biome distribution cannot use biome pages");
// Native noise biomes are supplied by Minecraft's preset, not chosen as Pages.
return new AgeBiomeSelection(distribution, profile.terrain() == AgeRuntimeProfile.Terrain.FLAT
? List.of(ResourceLocation.withDefaultNamespace("plains"))
: isCave(profile) ? availableBiomes : presetBiomes(registries, profile));
@@ -23,7 +23,8 @@ public record AgeDefinition(
@Nullable AgeSeasonMode seasonMode,
List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability,
AgeInstabilityPlan instabilityPlan
AgeInstabilityPlan instabilityPlan,
RandomPagePolicy randomPagePolicy
) {
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "random_vanilla"
@@ -39,6 +40,7 @@ public record AgeDefinition(
Objects.requireNonNull(traits, "traits");
Objects.requireNonNull(instability, "instability");
Objects.requireNonNull(instabilityPlan, "instabilityPlan");
Objects.requireNonNull(randomPagePolicy, "randomPagePolicy");
randomFeatures = List.copyOf(randomFeatures);
if (plan != null && !randomFeatures.isEmpty()) {
throw new IllegalArgumentException("Authored Ages cannot also have random features");
@@ -88,6 +90,16 @@ public record AgeDefinition(
AgeInstabilityPlan.empty());
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode, List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability, AgeInstabilityPlan instabilityPlan) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, instability,
instabilityPlan, RandomPagePolicy.UNRESTRICTED);
}
public static ResourceLocation dimensionId(UUID ageId) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + ageId);
}
@@ -117,7 +129,7 @@ public record AgeDefinition(
public AgeDefinition withSpawn(BlockPos spawn) {
return new AgeDefinition(id, name, seed, generationProfile, generationVersion,
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode,
randomFeatures, instability, instabilityPlan);
randomFeatures, instability, instabilityPlan, randomPagePolicy);
}
/** A frozen authored profile takes precedence over the profile ID on old books. */
@@ -16,6 +16,7 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
@@ -84,6 +85,7 @@ public final class AgeDimensionService {
if (level != null) {
applyCelestialClock(level, age);
SereneSeasonsCompatibility.registerAge(server, age);
RuinsCompatibility.registerAge(age);
}
}
synchronizeImmersivePortals(server);
@@ -141,6 +143,7 @@ public final class AgeDimensionService {
if (existing != null) {
applyCelestialClock(existing, definition);
SereneSeasonsCompatibility.registerAge(server, definition);
RuinsCompatibility.registerAge(definition);
return existing;
}
@@ -157,6 +160,7 @@ public final class AgeDimensionService {
} else {
applyCelestialClock(loaded, definition);
SereneSeasonsCompatibility.registerAge(server, definition);
RuinsCompatibility.registerAge(definition);
synchronizeImmersivePortals(server);
}
return loaded;
@@ -461,7 +465,7 @@ public final class AgeDimensionService {
AgeBiomeSelection selection = age.plan() != null && age.plan().biomeSelection() != null
? age.plan().biomeSelection()
: AgeBiomeSelection.compile(net.dnorberg.rivencraft.writing.WritingGrammar.parse(OrderedWriting.EMPTY),
age.traits(), profile, age.seed(), server.registryAccess());
age.traits(), profile, age.seed(), server.registryAccess(), age.randomPagePolicy());
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
List<Holder<Biome>> biomes = new ArrayList<>();
List<ResourceLocation> fallbacks = AgeBiomeSelection.eligible(server.registryAccess(), profile);
@@ -108,11 +108,7 @@ public final class AgeEnvironmentCompiler {
/** An authored color is fixed; -1 means the biome or selected vanilla sky supplies it. */
public static int skyColor(AgeGenerationPlan plan) {
if (plan == null) return -1;
return AgeSkyProfile.mixColors(plan.activeSymbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SKY_COLOR)
.map(symbol -> ((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id())
.map(AgeSkyProfile::color).toList());
return AgeSkyProfile.authoredSkyColor(plan);
}
public static int selectedSkyColor(AgeGenerationPlan plan, ResourceLocation fallback, int biomeSkyColor) {
@@ -159,15 +155,19 @@ public final class AgeEnvironmentCompiler {
Double pendingPeriod = null;
Float pendingPhase = null;
boolean writtenPhase = false;
Double pendingNumber = null;
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
SymbolSemantics semantics = symbol.semantics();
Double number = pendingNumber;
if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null;
switch (semantics.role()) {
case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue) semantics.value()).amount();
case PERIOD_MODIFIER -> {
double next = ((SymbolSemantics.NumberValue) semantics.value()).amount();
double next = CelestialPageValues.value(semantics, number);
pendingPeriod = pendingPeriod == null ? next : (pendingPeriod + next) / 2.0D;
}
case PHASE_MODIFIER -> {
float next = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
float next = (float) CelestialPageValues.value(semantics, number);
pendingPhase = pendingPhase == null ? next : circularMidpoint(pendingPhase, next);
}
case CELESTIAL -> {
@@ -10,6 +10,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.Random;
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.dnorberg.rivencraft.writing.WritingGrammar;
@@ -157,12 +158,19 @@ public final class AgeFeatureSelection {
/** Page-free Ages roll up to two material features, plus rare Skylands, once at creation. */
public static List<Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries) {
return randomChoices(ageSeed, profile, registries, RandomPagePolicy.UNRESTRICTED);
}
public static List<Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries,
RandomPagePolicy policy) {
Random random = new Random(ageSeed ^ 0x4D4154455249414CL);
int count = random.nextInt(3);
List<LegacyRankChoice.Option<ResourceLocation>> features = new ArrayList<>();
for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null)
for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null
&& policy.allows(featurePage(id)))
features.add(new LegacyRankChoice.Option<>(id, randomMaterialRank(id)));
for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null)
for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null
&& policy.allows(featurePage(id)))
features.add(new LegacyRankChoice.Option<>(id, randomMaterialRank(id)));
List<Choice> choices = new ArrayList<>();
while (choices.size() < count && !features.isEmpty()) {
@@ -171,23 +179,30 @@ public final class AgeFeatureSelection {
var category = solidFeature(feature)
? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY;
List<ResourceLocation> materials = BlockSymbolPages.availableBlocks(category).stream()
.filter(id -> policy.allows(BlockSymbolPages.symbolId(id)))
.filter(id -> materialError(new Choice(feature, Optional.of(id))) == null).toList();
if (!materials.isEmpty()) choices.add(new Choice(feature,
Optional.of(materials.get(random.nextInt(materials.size())))));
}
if (profile.terrain() == AgeRuntimeProfile.Terrain.NOISE
&& policy.allows(featurePage(id("skylands")))
&& Math.floorMod(mix(ageSeed ^ RANDOM_SKYLANDS_SALT), 8) == 0)
choices.add(new Choice(id("skylands"), Optional.empty()));
if (error(CRYSTAL_FORMATIONS, profile, registries) == null && random.nextInt(10) == 0)
if (error(CRYSTAL_FORMATIONS, profile, registries) == null
&& policy.allows(featurePage(CRYSTAL_FORMATIONS)) && random.nextInt(10) == 0)
choices.add(new Choice(CRYSTAL_FORMATIONS, Optional.empty()));
if (randomStarFissure(ageSeed))
if (policy.allows(featurePage(STAR_FISSURES)) && randomStarFissure(ageSeed))
choices.add(new Choice(STAR_FISSURES, Optional.empty()));
AgeBiomeSelection biomes = AgeBiomeSelection.compile(WritingGrammar.parse(OrderedWriting.EMPTY),
AgeTraits.select(ageSeed), profile, ageSeed, registries);
choices.addAll(AgeStructureSelection.randomChoices(ageSeed, profile, biomes, registries));
AgeTraits.select(ageSeed, policy), profile, ageSeed, registries, policy);
choices.addAll(AgeStructureSelection.randomChoices(ageSeed, profile, biomes, registries, policy));
return List.copyOf(choices);
}
private static ResourceLocation featurePage(ResourceLocation id) {
return ResourceLocation.fromNamespaceAndPath("rivencraft", "feature/" + id.getPath());
}
static int randomMaterialRank(ResourceLocation feature) {
if (feature.equals(id("surface_lakes")) || feature.equals(id("deep_lakes"))) return 1;
if (feature.equals(id("lava_lakes"))) return 2; // New separate fluid choice.
@@ -235,6 +250,9 @@ public final class AgeFeatureSelection {
}
public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) {
if (RuinsTemplatePages.isSymbol(choice))
return RuinsTemplatePages.contains(choice) ? null : "Ruins template " + choice
+ " is unavailable; restore Ruins and its template before loading this Age";
if (AgeStructureSelection.feature(choice))
return AgeStructureSelection.error(choice, profile, null, registries);
if (choice.equals(AgeDenseOres.ID))
@@ -188,7 +188,7 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
String raw = item.getString("Value");
SymbolSemantics.Value value = switch (role) {
case TERRAIN, BIOME, BLOCK, FEATURE -> new SymbolSemantics.ResourceValue(ResourceLocation.parse(raw));
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER, SIZE_MODIFIER ->
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER, SIZE_MODIFIER, COLOR_NUMBER ->
new SymbolSemantics.NumberValue(Double.parseDouble(raw));
default -> new SymbolSemantics.ChoiceValue(raw);
};
@@ -6,6 +6,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
import net.minecraft.core.RegistryAccess;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
@@ -55,6 +56,11 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
}
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries, long ageSeed) {
return compute(plan, traits, registries, ageSeed, RandomPagePolicy.UNRESTRICTED);
}
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries,
long ageSeed, RandomPagePolicy policy) {
Objects.requireNonNull(traits, "traits");
List<Contribution> contributions = new ArrayList<>();
if (plan == null) {
@@ -86,7 +92,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
contributions.add(new Contribution("bright_lighting", -1, BRIGHT_LIGHTING_COST,
"Seeded bright lighting"));
}
AgeSkyProfile sky = plan == null && ageSeed != Long.MIN_VALUE ? AgeSkyProfile.random(ageSeed, traits)
AgeSkyProfile sky = plan == null && ageSeed != Long.MIN_VALUE ? AgeSkyProfile.random(ageSeed, traits, policy)
: plan == null ? new AgeSkyProfile(AgeSkyProfile.Style.INHERITED, -1, List.of())
: AgeSkyProfile.compile(plan, traits);
int seenSuns = 0;
@@ -150,9 +156,12 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|| !(symbol.semantics().value()
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ResourceValue resource)) continue;
String name = COUNTED_STRUCTURES.get(resource.id());
if (name == null && RuinsTemplatePages.isSymbol(resource.id()))
name = RuinsTemplatePages.fallbackName(resource.id());
if (name == null) continue;
int count = structureCounts.merge(resource.id(), 1, Integer::sum);
if (AgeStructureSelection.requiresBiomeOverride(resource.id(), plan.biomeSelection(), registries)) {
if (COUNTED_STRUCTURES.containsKey(resource.id())
&& AgeStructureSelection.requiresBiomeOverride(resource.id(), plan.biomeSelection(), registries)) {
contributions.add(new Contribution("forced_biome_" + resource.id().getPath(), symbol.index(),
FORCED_STRUCTURE_BIOME_COST,
name + " occurrence " + count + " is forced into an unsupported biome"));
@@ -168,7 +177,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
static int featureCost(ResourceLocation feature, int occurrence) {
if (occurrence < 1) throw new IllegalArgumentException("Feature occurrence must be positive");
return COUNTED_STRUCTURES.containsKey(feature) && occurrence > 3
return (COUNTED_STRUCTURES.containsKey(feature) || RuinsTemplatePages.isSymbol(feature)) && occurrence > 3
? EXCESS_LEGACY_STRUCTURE_COST : 0;
}
@@ -125,9 +125,9 @@ public final class AgePersistenceGameTests {
new AgeReference(UUID.randomUUID(), "Stable Writing"), stable);
AgeDefinition defectiveAge = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Defective Writing"), defective);
helper.assertValueEqual(stableAge.instability().score(), 0,
helper.assertValueEqual(nonCelestialInstability(stableAge), 0,
"Missing seeded branches do not add instability");
helper.assertValueEqual(defectiveAge.instability().score(), 600,
helper.assertValueEqual(nonCelestialInstability(defectiveAge), 600,
"Out-of-order and repeated terrain pages each add their saved penalty");
helper.assertTrue(defectiveAge.instability().breakdown().stream()
.anyMatch(part -> part.reason().equals("duplicate") && part.pageIndex() == 3),
@@ -184,9 +184,9 @@ public final class AgePersistenceGameTests {
new AgeReference(UUID.randomUUID(), "Three " + structure), structureWriting(structure, 3));
AgeDefinition four = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Four " + structure), structureWriting(structure, 4));
helper.assertValueEqual(three.instability().score(), 0,
helper.assertValueEqual(nonCelestialInstability(three), 0,
"The first three " + structure + " pages retain Legacy's zero cost");
helper.assertValueEqual(four.instability().score(), 100,
helper.assertValueEqual(nonCelestialInstability(four), 100,
"The fourth " + structure + " page must add Legacy's 100-point cost");
helper.assertTrue(four.instability().breakdown().stream().anyMatch(part ->
part.reason().equals("excess_" + structure) && part.pageIndex() == 8),
@@ -207,6 +207,13 @@ public final class AgePersistenceGameTests {
"Modern repeated structures must use the same deterministic scoring path");
helper.assertValueEqual(AgeInstability.featureCost(ResourceLocation.parse("rivencraft:dungeons"), 4), 0,
"A Legacy-neutral feature must remain neutral when repeated");
ResourceLocation ruin = net.dnorberg.rivencraft.compat.RuinsTemplatePages.symbolId("ZombieHut.tml");
helper.assertValueEqual(AgeInstability.featureCost(ruin, 1), 0,
"The first Ruins template page is free like a standard structure");
helper.assertValueEqual(AgeInstability.featureCost(ruin, 3), 0,
"The first three copies of a Ruins page are free");
helper.assertValueEqual(AgeInstability.featureCost(ruin, 4), 100,
"Each Ruins page beyond the third adds 100 instability");
OrderedWriting duplicateBright = new OrderedWriting(List.of(
WritingEntry.linkPanel(),
@@ -216,7 +223,7 @@ public final class AgePersistenceGameTests {
));
AgeDefinition bright = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Double Bright"), duplicateBright);
helper.assertValueEqual(bright.instability().score(), 1500,
helper.assertValueEqual(nonCelestialInstability(bright), 1500,
"Each Bright Lighting page costs 500 and the extra controller costs another 500");
helper.assertValueEqual(bright.instability().breakdown().stream()
.filter(part -> part.reason().equals("bright_lighting")).count(), 2L,
@@ -420,6 +427,13 @@ public final class AgePersistenceGameTests {
helper.succeed();
}
/** Isolate the writing-cost assertion from seed-completed extra-sun instability. */
private static int nonCelestialInstability(AgeDefinition age) {
return age.instability().breakdown().stream()
.filter(part -> !part.reason().equals("extra_sun"))
.mapToInt(AgeInstability.Contribution::points).sum();
}
private static CompoundTag entry(
UUID id,
String name,
@@ -39,6 +39,7 @@ public final class AgeRegistrySavedData extends SavedData {
private static final String TRAITS_TAG = "Traits";
private static final String PLAN_TAG = "GenerationPlan";
private static final String RANDOM_FEATURES_TAG = "RandomFeatures";
private static final String RANDOM_PAGE_POLICY_TAG = "RandomPagePolicy";
private static final String SEASON_MODE_TAG = "SeasonMode";
private static final String INSTABILITY_TAG = "Instability";
private static final String INSTABILITY_PLAN_TAG = "InstabilityPlan";
@@ -77,9 +78,19 @@ public final class AgeRegistrySavedData extends SavedData {
ServerLevel overworld = level.getServer().overworld();
AgeRegistrySavedData data = get(overworld);
long seed = ageSeed(overworld.getSeed(), data.availableDimensionId(normalizedName(name)));
AgeGenerationPlan.compile(writing, seed,
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed,
AgeRuntimeProfiles.selectForWriting(seed, writing, overworld.registryAccess()), AgeTraits.select(seed),
overworld.registryAccess());
if (plan == null) {
RandomPagePolicy policy = RandomPagePolicy.configured();
ResourceLocation profileId = AgeRuntimeProfiles.select(seed, policy);
AgeRuntimeProfile profile = AgeRuntimeProfiles.resolve(profileId);
AgeTraits traits = AgeTraits.select(seed, policy);
AgeBiomeSelection.compile(net.dnorberg.rivencraft.writing.WritingGrammar.parse(OrderedWriting.EMPTY),
traits, profile, seed, overworld.registryAccess(), policy);
AgeSkyProfile.random(seed, traits, policy);
AgeSeasonMode.select(null, seed, ModList.get().isLoaded("sereneseasons"), profile, policy);
}
}
public static ResourceLocation previewGenerationProfile(ServerLevel level, String name) {
@@ -155,7 +166,8 @@ public final class AgeRegistrySavedData extends SavedData {
AgeDefinition changed = new AgeDefinition(previous.id(), previous.name(), seed,
previous.generationProfile(), previous.generationVersion(), previous.createdGameTime(),
previous.dimensionId(), previous.spawn(), previous.traits(), plan, previous.seasonMode(),
previous.randomFeatures(), previous.instability(), previous.instabilityPlan());
previous.randomFeatures(), previous.instability(), previous.instabilityPlan(),
previous.randomPagePolicy());
if (AgeDimensionService.unload(server, previous) == AgeDimensionService.UnloadResult.FAILED)
return false;
ages.put(ageId, changed);
@@ -173,13 +185,19 @@ public final class AgeRegistrySavedData extends SavedData {
String name = normalizedName(reference.name());
ResourceLocation dimensionId = availableDimensionId(name);
long seed = ageSeed(worldSeed, dimensionId);
RandomPagePolicy policy = RandomPagePolicy.configured();
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
AgeTraits traits = AgeTraits.select(seed);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
if (plan == null) {
seedProfile = AgeRuntimeProfiles.select(seed, policy);
traits = AgeTraits.select(seed, policy);
}
AgeTraits effectiveTraits = plan == null
? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile))
: AgeEnvironmentCompiler.apply(plan, traits);
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries, seed);
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries, seed,
plan == null ? policy : RandomPagePolicy.UNRESTRICTED);
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
AgeDefinition created = new AgeDefinition(
reference.id(),
@@ -193,11 +211,13 @@ public final class AgeRegistrySavedData extends SavedData {
effectiveTraits,
plan,
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"),
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile()),
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile(),
plan == null ? policy : RandomPagePolicy.UNRESTRICTED),
plan == null ? AgeFeatureSelection.randomChoices(seed,
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
AgeRuntimeProfiles.resolve(seedProfile), registries, policy) : List.of(),
instability,
instabilityPlan
instabilityPlan,
plan == null ? policy : RandomPagePolicy.UNRESTRICTED
);
ages.put(created.id(), created);
setDirty();
@@ -264,7 +284,10 @@ public final class AgeRegistrySavedData extends SavedData {
? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null,
readRandomFeatures(entry),
instability,
instabilityPlan
instabilityPlan,
entry.contains(RANDOM_PAGE_POLICY_TAG, CompoundTag.TAG_COMPOUND)
? RandomPagePolicy.load(entry.getCompound(RANDOM_PAGE_POLICY_TAG))
: RandomPagePolicy.UNRESTRICTED
));
}
return data;
@@ -309,6 +332,7 @@ public final class AgeRegistrySavedData extends SavedData {
entry.put(RANDOM_FEATURES_TAG, featureTags);
}
if (age.seasonMode() != null) entry.putString(SEASON_MODE_TAG, age.seasonMode().name());
if (age.plan() == null) entry.put(RANDOM_PAGE_POLICY_TAG, age.randomPagePolicy().save());
entry.put(INSTABILITY_TAG, age.instability().save());
entry.put(INSTABILITY_PLAN_TAG, age.instabilityPlan().save());
serializedAges.add(entry);
@@ -57,20 +57,39 @@ public final class AgeRuntimeProfiles {
/** Selects from all valid loaded profiles in stable resource-ID order. */
public static ResourceLocation select(long seed) {
return select(seed, RandomPagePolicy.UNRESTRICTED);
}
public static ResourceLocation select(long seed, RandomPagePolicy policy) {
if (PROFILES.isEmpty()) {
return AgeGenerationProfiles.select(seed);
List<LegacyRankChoice.Option<ResourceLocation>> profiles = policy.required(
AgeGenerationProfiles.all().stream().sorted()
.map(id -> new LegacyRankChoice.Option<>(id, AgeGenerationProfiles.randomRank(id)))
.toList(), option -> terrainPage(option.value()),
new LegacyRankChoice.Option<>(AgeGenerationProfiles.STANDARD, 1));
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), profiles);
}
List<LegacyRankChoice.Option<ResourceLocation>> profiles = PROFILES.keySet().stream().sorted()
List<LegacyRankChoice.Option<ResourceLocation>> available = PROFILES.keySet().stream().sorted()
.map(id -> new LegacyRankChoice.Option<>(id, AgeGenerationProfiles.randomRank(id))).toList();
LegacyRankChoice.Option<ResourceLocation> fallback = available.stream()
.filter(option -> option.value().equals(AgeGenerationProfiles.STANDARD))
.findFirst().orElse(available.getFirst());
List<LegacyRankChoice.Option<ResourceLocation>> profiles = policy.required(available,
option -> terrainPage(option.value()), fallback);
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), profiles);
}
private static ResourceLocation terrainPage(ResourceLocation profile) {
return ResourceLocation.fromNamespaceAndPath("rivencraft", "terrain/" + profile.getPath());
}
/** Keep seed-selected terrain random, but only among profiles able to host authored features. */
public static ResourceLocation selectForWriting(long seed, OrderedWriting writing, RegistryAccess registries) {
ResourceLocation selected = select(seed);
WritingGrammar.Result parsed = WritingGrammar.parse(writing);
if (parsed.activeSymbols().stream().anyMatch(symbol ->
symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)) return selected;
boolean authoredTerrain = parsed.activeSymbols().stream().anyMatch(symbol ->
symbol.semantics().role() == SymbolSemantics.Role.TERRAIN);
ResourceLocation selected = select(seed);
if (authoredTerrain) return selected;
List<ResourceLocation> features = parsed.activeSymbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList();
@@ -24,15 +24,23 @@ public enum AgeSeasonMode {
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
AgeRuntimeProfile profile) {
return select(plan, ageSeed, sereneInstalled, profile, RandomPagePolicy.UNRESTRICTED);
}
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
AgeRuntimeProfile profile, RandomPagePolicy policy) {
if (profile != null && AgeBiomeSelection.isCave(profile)) return NO_SEASONS;
if (hasAuthoredPage(plan)) return from(plan);
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), policy.required(List.of(
new LegacyRankChoice.Option<>(SEASONS, 1),
new LegacyRankChoice.Option<>(NO_SEASONS, 2),
new LegacyRankChoice.Option<>(ETERNAL_WINTER, 3),
new LegacyRankChoice.Option<>(ETERNAL_SUMMER, 3),
new LegacyRankChoice.Option<>(ETERNAL_FALL, 3),
new LegacyRankChoice.Option<>(ETERNAL_SPRING, 3))) : null;
new LegacyRankChoice.Option<>(ETERNAL_SPRING, 3)),
option -> net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("rivencraft",
"season/" + option.value().name().toLowerCase(Locale.ROOT)),
new LegacyRankChoice.Option<>(NO_SEASONS, 2))) : null;
}
/** Existing Ages without a stored choice retain their old cycling behavior. */
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more