Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda19de43c | ||
|
|
0fb2f7c1bb | ||
|
|
4f07f675a4 | ||
|
|
b8be99606f | ||
|
|
98595c83ea | ||
|
|
e7e2cf7e5a | ||
|
|
77f15f8599 | ||
|
|
2b2605b277 | ||
|
|
2f1e6bd6f2 | ||
|
|
9d5ff2a2cb | ||
|
|
3ac4c499ab | ||
|
|
9044835ec5 | ||
|
|
7ae41da23e | ||
|
|
9cb63e6994 | ||
|
|
6337b5aa10 | ||
|
|
b9faa28dd5 | ||
|
|
09d2b0800c | ||
|
|
ee905cab68 | ||
|
|
5c08f391a2 | ||
|
|
b8d26a9036 | ||
|
|
d0af75239a | ||
|
|
2e5d6e0427 | ||
|
|
54c10cca6c | ||
|
|
c39ebd78ba | ||
|
|
1ff62e9aaa | ||
|
|
b154455563 |
No files matched your search
@@ -1,253 +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.
|
||||
|
||||
Craft a Book Binder from three iron ingots and five planks. Put leather or an
|
||||
empty Notebook in the left cover slot. Enter a title, then place a Link Panel
|
||||
Page first in the horizontal page strip. Click a page in the strip with a
|
||||
written Page on the cursor to insert it before that position; click it with an
|
||||
empty cursor to take it out. The arrows or mouse wheel scroll through up to
|
||||
27 written Pages. Clicking the strip with a filled Notebook imports its Pages
|
||||
at that position and leaves the emptied Notebook on the cursor. The right slot
|
||||
outputs a Descriptive Book once the title, cover, and Link Panel are present.
|
||||
Inputs are consumed only when the result is taken.
|
||||
## Requirements and optional integrations
|
||||
|
||||
The completed book receives its Age identity when assembled. With only a Link
|
||||
Panel, it describes a randomly generated Age. 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.
|
||||
Install the same gameplay mods on the client and server:
|
||||
|
||||
## Linking Book
|
||||
- Minecraft **1.21.1**, NeoForge **21.1.264 or newer in the 21.1 line**, and
|
||||
Java 21.
|
||||
- **Dynamic Dimensions AR Edition 0.9.1** and its **Bad Packets 0.8.2**
|
||||
dependency. These are required for Age dimensions.
|
||||
|
||||
Craft an unwritten Linking Book from one Link Panel Page and one leather in any
|
||||
two crafting-grid positions. An unbound book opens a screen where it can be named and bound to the
|
||||
current location. A bound book opens its destination screen and travels when
|
||||
its preview panel is clicked. The destination is stored on the item and
|
||||
survives inventory and world saves. Travel only succeeds if the target
|
||||
dimension is loaded and a non-fluid, passable landing position can be found
|
||||
nearby.
|
||||
The Linking Book uses the same Legacy page layout and bottom-corner page turns
|
||||
as the Descriptive Book, but without the Descriptive Book's gold cover edges.
|
||||
Both screens play Minecraft's book-page-turn sound when a page is turned.
|
||||
Rivencraft works without any of these optional integrations:
|
||||
|
||||
Successful book travel plays Mystcraft Legacy's original linking sound at both
|
||||
the departure and arrival locations.
|
||||
| Mod | What it adds |
|
||||
| --- | --- |
|
||||
| JEI | Searchable Pages and guides for the Ink Mixer, Writing Desk, Book Binder, and Linking Book crafting. |
|
||||
| Create (bundles Ponder) | Ponder book snapshots and a crystal-portal construction guide. |
|
||||
| Immersive Portals | Live destination views in book previews and crystal portals. In `AUTO` preview mode it takes priority over Ponder. |
|
||||
| TerraBlender | Eligible biomes from mods using its Overworld/Nether regions become Age biome candidates and Pages. Biomes O' Plenty is one example; it is not hard-coded or required. |
|
||||
| Serene Seasons (with GlitchCore) | Independent per-Age seasons, including normal seasons, no seasons, and four eternal-season modes. |
|
||||
| Ruins | Authorable Ruins-template Pages and Ruins structures in Ages. |
|
||||
|
||||
Right-click an empty vanilla lectern with a Rivencraft book to convert it into
|
||||
a Book Stand and place the book on it. Use a Linking Book on the stand by
|
||||
right-clicking with an empty hand. Sneak-right-click with an empty hand to take
|
||||
the book back and restore the vanilla lectern. Breaking a Book Stand drops both
|
||||
the stored book and a vanilla lectern.
|
||||
The optional builds used in the latest development checks are JEI
|
||||
19.69.0.457, Create 6.0.10, Immersive Portals 6.0.7, TerraBlender 4.1.0.8,
|
||||
Serene Seasons 10.1.0.9, and Ruins 1.21.1.2NF. See the
|
||||
[compatibility review](docs/MILESTONE_7_COMPATIBILITY_REVIEW.md) for the
|
||||
evidence and limits of those combinations.
|
||||
|
||||
Bound Linking Books show a rotating Ponder snapshot when Create is installed.
|
||||
Without Create, they use a built-in animated linking panel tinted by the
|
||||
destination sky. The server independently validates every link.
|
||||
Without Create or Immersive Portals, book previews use Rivencraft's built-in
|
||||
Legacy-style animation. Ecliptic Seasons is **not supported**: its 1.21.1 API
|
||||
does not offer the live dimension/season control Rivencraft needs.
|
||||
|
||||
## Descriptive Book
|
||||
## Saves, settings, and current limits
|
||||
|
||||
Open a page-free Descriptive Book, name its Age, and press Create. Rivencraft
|
||||
assigns the book a permanent Age UUID, selects a deterministic base terrain,
|
||||
creates a dedicated runtime dimension, and prepares a five-by-five cobblestone
|
||||
arrival platform at the
|
||||
highest suitable nearby terrain. Solid blocks and tree leaves are valid
|
||||
footing. Candidate columns are scanned from the top down so cave floors are not
|
||||
selected, and fluid-covered columns are excluded. If no suitable surface is
|
||||
available, the platform is generated at Y=63. Reopen the book and click its preview to travel.
|
||||
The book cannot reopen while its Age is being generated, whether held or on a
|
||||
Book Stand. Once creation finishes, reopening shows the written book.
|
||||
Copies of the written book continue to resolve to the same persistent Age
|
||||
across server restarts.
|
||||
Ages and bound books use persistent UUIDs. Back up the **entire world folder**
|
||||
before changing mod versions, data packs, or optional biome mods: the Age
|
||||
registry and dimension data must be restored together. Rivencraft preserves
|
||||
known older save formats where possible, but pre-public-release worlds are not
|
||||
promised universal compatibility. Missing custom symbols retain their IDs in
|
||||
books; missing registry entries or dimension providers can still prevent an
|
||||
Age from loading. See [current implementation](docs/CURRENT_IMPLEMENTATION.md)
|
||||
for supported behavior and deliberate limits.
|
||||
|
||||
New Age dimension folders use a Minecraft-safe lowercase form of the assigned
|
||||
name. Conflicts receive incrementing suffixes such as `_2` and `_3`; existing
|
||||
Age folders are never renamed.
|
||||
Server settings include `descriptive_books.return_link_mode` (default
|
||||
`DISABLED`), `libraries.loot_mode` (`LEGACY` or `AGE_ONLY`), writing ink costs,
|
||||
season enrollment, and instability controls. The client setting
|
||||
`book_preview.mode` chooses `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
|
||||
An operator can inspect Ages and instability with `/rivencraft age` and find
|
||||
libraries with `/rivencraft library nearest`. The creative-only Link Modifier
|
||||
can edit link properties and retire an Age; use it carefully and keep backups.
|
||||
Binder status messages are off by default. An OP can run
|
||||
`/rivencraft binder messages enable` or `/rivencraft binder messages disable`
|
||||
to control them; only OPs see the diagnostics even when enabled.
|
||||
|
||||
Page-free Ages deterministically select one of six base terrains inspired by
|
||||
Mystcraft Legacy: Standard, Amplified, Island, Cave, Flat, or Void. The chosen
|
||||
profile is saved with the Age and does not change when the server restarts.
|
||||
## Project and development
|
||||
|
||||
Linking creates a bound Return Link to the exact source position and orientation
|
||||
according to the configured Return Link policy.
|
||||
This repository is the development source. See the [current implementation](docs/CURRENT_IMPLEMENTATION.md),
|
||||
[milestone summaries](docs/MILESTONES.md), [technical document index](docs/README.md),
|
||||
and [issues](https://git.paddedcell.org/dnorberg/rivencraft/issues).
|
||||
Help build the [Rivencraft wiki](https://git.paddedcell.org/dnorberg/rivencraft/wiki)
|
||||
with guides for Ages, pages, and crafting, or open an issue with documentation
|
||||
suggestions and corrections.
|
||||
Milestone 7 is complete in 1.0.0.2. Its [acceptance record](docs/MILESTONE_7_ACCEPTANCE.md)
|
||||
and [regression matrix](docs/MILESTONE_7_REGRESSION.md) distinguish automated
|
||||
checks from manual evidence and known limitations.
|
||||
|
||||
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
|
||||
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
|
||||
Age creation, persistence, lifecycle commands, configuration, backups, and
|
||||
administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
|
||||
Data-pack runtime profiles are documented in
|
||||
[docs/AGE_PROFILES.md](docs/AGE_PROFILES.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Java 21 JDK
|
||||
- Minecraft 1.21.1
|
||||
- NeoForge 21.1.251 or newer in the 21.1 line
|
||||
- Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server)
|
||||
|
||||
Create is optional. When installed, Rivencraft uses its Ponder renderer for
|
||||
book previews when Immersive Portals is absent. Immersive Portals is also
|
||||
optional and takes priority in the default `AUTO` mode, providing a live
|
||||
rotating destination view. Its Explore button enables WASD and jump/sneak
|
||||
camera movement plus left-drag camera look; right-click returns to the rotating
|
||||
view. Without either integration, Rivencraft uses its built-in animated linking
|
||||
panel. Clients can set `book_preview.mode` in
|
||||
`rivencraft-client.toml` to `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
|
||||
Unavailable optional renderers safely fall back to `LEGACY`.
|
||||
The complete backend matrix, supported combinations, cleanup behavior, and
|
||||
known upstream limits are documented in
|
||||
[docs/PREVIEW_COMPATIBILITY.md](docs/PREVIEW_COMPATIBILITY.md).
|
||||
|
||||
## Build and run
|
||||
|
||||
```shell
|
||||
```sh
|
||||
./gradlew build
|
||||
./gradlew runClient
|
||||
./gradlew runGameTestServer
|
||||
```
|
||||
|
||||
The built mod is written to `build/libs/`.
|
||||
The default dedicated-server GameTest run is deliberately small. Use
|
||||
`./gradlew runGameTestServer -PwithArchivedGameTests=true` for the complete
|
||||
regression suite before a release or when investigating a related issue. See
|
||||
[docs/GAMETESTS.md](docs/GAMETESTS.md).
|
||||
The JAR is written to `build/libs/`. Development runs need the required mods;
|
||||
see [GameTest guidance](docs/GAMETESTS.md) for focused archived suites.
|
||||
Versions use `x.y.z.b`: major release, completed milestone, release slice,
|
||||
then fixes/additional work within that slice.
|
||||
|
||||
## Versioning
|
||||
|
||||
Rivencraft versions use `x.y.z.b`:
|
||||
|
||||
- `x` changes for a major public release.
|
||||
- `y` changes when a development milestone is completed.
|
||||
- `z` changes for each release slice within the current milestone.
|
||||
- `b` changes for bug fixes and additional work within that release slice.
|
||||
|
||||
The build component resets to zero when a new release slice begins. Completing
|
||||
a milestone increments `y` and resets both `z` and `b` to zero. For example,
|
||||
milestone 2 development can progress from `0.1.1.0` to `0.1.1.1`, then
|
||||
`0.1.2.0`; completing the milestone produces `0.2.0.0`.
|
||||
|
||||
## Porting approach
|
||||
|
||||
Mystcraft Legacy targets Forge for Minecraft 1.12.2. Rivencraft therefore
|
||||
ports one subsystem at a time onto current registries, codecs, data packs, and
|
||||
NeoForge APIs instead of importing the legacy source tree wholesale.
|
||||
|
||||
See [docs/PORTING.md](docs/PORTING.md) for the roadmap, live Gitea tracking
|
||||
links, architectural direction, and provenance rules.
|
||||
|
||||
## License
|
||||
|
||||
Rivencraft is licensed under LGPL-3.0-or-later. Mystcraft Legacy is also
|
||||
LGPL-3.0; copied or adapted upstream code must retain its copyright and license
|
||||
notices. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
Rivencraft is licensed under LGPL-3.0-or-later. Legacy-derived material is
|
||||
credited in [NOTICE](NOTICE) and [asset provenance](docs/ASSET_PROVENANCE.md).
|
||||
@@ -132,6 +132,8 @@ neoForge {
|
||||
|
||||
server {
|
||||
server()
|
||||
// Keep dedicated compatibility runs away from the normal dev world.
|
||||
gameDirectory = project.file(project.findProperty('serverRunDir') ?: 'run')
|
||||
programArgument '--nogui'
|
||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||
}
|
||||
@@ -243,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()) {
|
||||
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 854 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 673 KiB |
@@ -11,7 +11,7 @@ suffixes such as `_2` and `_3`.
|
||||
|
||||
The selected terrain profile, biome distribution, lighting, weather, celestial
|
||||
period, celestial angle, and phase are deterministic and become part of the
|
||||
saved definition. A five-by-five cobblestone platform with clear headroom is
|
||||
saved definition. A five-by-five non-dropping Crystal Platform with clear headroom is
|
||||
prepared at a safe arrival point. The completed book and every copy carrying
|
||||
the same UUID resolve to that definition.
|
||||
|
||||
@@ -42,10 +42,11 @@ on demand when an administrator previously unloaded it. Unloading retains the
|
||||
definition and world folder, so existing books continue to reference the same
|
||||
Age and later reload the same terrain.
|
||||
|
||||
Rivencraft does not automatically delete Ages. Safe permanent deletion needs a
|
||||
complete reference index for books in inventories, containers, Book Stands,
|
||||
and unloaded chunks. Until that exists, retaining the definition prevents an
|
||||
administrator action from silently orphaning books.
|
||||
Rivencraft does not automatically delete Ages. The operator-only creative Link
|
||||
Modifier can deliberately retire an unoccupied Age: it unloads the dimension,
|
||||
moves its data to a sibling `<world>-retired-ages/<uuid>` archive, and records
|
||||
the UUID as dead so existing books cannot silently regenerate it. Back up the
|
||||
whole world first.
|
||||
|
||||
Destination-preview chunk loaders are temporary. Rivencraft releases them when
|
||||
the book closes, the player travels, the player disconnects, the destination
|
||||
@@ -69,10 +70,9 @@ definition.
|
||||
Rivencraft writes gameplay settings to the world/server configuration managed
|
||||
by NeoForge. `descriptive_books.return_link_mode` accepts:
|
||||
|
||||
- `ON_CREATION` (default): create one Return Link the first time a player
|
||||
enters each Age.
|
||||
- `DISABLED` (default): never create Return Links automatically.
|
||||
- `ON_CREATION`: create one Return Link the first time a player enters each Age.
|
||||
- `ALWAYS`: create a Return Link after every successful Descriptive Book trip.
|
||||
- `DISABLED`: never create Return Links automatically.
|
||||
|
||||
Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode`
|
||||
setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes
|
||||
@@ -102,8 +102,8 @@ Use this sequence when a book cannot load an existing Age:
|
||||
matching dimension data and authoritative world saved data from the same
|
||||
backup generation. Do not combine them from different backups.
|
||||
|
||||
There is intentionally no delete command in Milestone 3. Do not manually
|
||||
delete an Age definition or rename its dimension folder: books may exist in
|
||||
inventories, containers, Book Stands, or unloaded chunks, and would be
|
||||
orphaned. `/rivencraft age unload` is the supported reversible maintenance
|
||||
operation.
|
||||
Do not manually delete an Age definition or rename its dimension folder:
|
||||
books may exist in inventories, containers, Book Stands, or unloaded chunks.
|
||||
`/rivencraft age unload` remains the reversible maintenance operation. The
|
||||
Link Modifier's retirement control is not reversible through a book UI;
|
||||
recover its archived data and registry together from a full-world backup.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Age arrival cloud platform
|
||||
# Age arrival platform
|
||||
|
||||
Newly created Ages now place a 5×5 solid cloud platform instead of cobblestone at their saved spawn. The block has full collision, passes skylight, renders translucently, has no loot table, and cannot be obtained with pick-block. Existing Ages retain their already-placed platform; this change does not overwrite a player's saved world.
|
||||
Newly created Ages place a 5×5 solid platform instead of cobblestone at their saved spawn. The block has full collision, renders as an opaque full block, breaks as easily as glass (0.3 hardness), has no loot table, and cannot be obtained with pick-block. Existing Ages retain their already-placed platform blocks, which adopt the new appearance and hardness without altering saved terrain.
|
||||
|
||||
The block texture is `assets/rivencraft/textures/block/cloud_platform.png`, verified at exactly 16×16 RGBA. Legacy's supplied assets contain no cloud-block texture, so this one was generated with the built-in image tool, then reduced by nearest-neighbor sampling. The final prompt requested a seamless Minecraft block-face pixel-art tile with 2–3 chunky white, icy-blue, and powder-blue cloud swirls, genuine transparent holes between them, strong 16×16-scale contrast, and no background or text.
|
||||
The model reuses `assets/rivencraft/textures/block/crystal_block.png`, the 16×16 Legacy crystal tile. The former generated cloud texture is removed. The internal block ID remains `rivencraft:cloud_platform` to preserve saved worlds.
|
||||
|
||||
The separate [decay provider](INSTABILITY_DECAY.md) protects the saved spawn area by position, so its protection remains in force regardless of the platform's material.
|
||||
@@ -40,6 +40,27 @@ profiles, not claims that Legacy had identically named terrain symbols.
|
||||
Every family retains a safe cobblestone arrival platform. An omitted terrain
|
||||
page still uses the deterministic seed-selected profile.
|
||||
|
||||
### Cave terrain and Legacy
|
||||
|
||||
Legacy's closest terrain generator is its Nether terrain symbol: it owns an
|
||||
Age-seeded octave-noise generator, accepts terrain/sea materials, and sets a
|
||||
sea level of 32. Legacy's separate Caves symbol instead registers a terrain
|
||||
alteration that carves additional caves; it is not a terrain generator.
|
||||
Minecraft 1.21.1 no longer has Legacy's 1.12.2 height and generation pipeline,
|
||||
so Rivencraft Cave terrain uses `minecraft:caves` noise and the modern Cave,
|
||||
Nether, and tagged modded Nether biome decorations. It cannot reproduce the
|
||||
exact Legacy noise or its older vertical layout. The Caves feature page
|
||||
remains a separate additive carver for other suitable terrain.
|
||||
|
||||
Cave Ages search for safe spawn footing from Y=64 downward rather than using
|
||||
the cave roof; if necessary, the usual cloud platform is placed at Y=63.
|
||||
Weather is clear and Serene Seasons is disabled in Cave terrain. Authored
|
||||
Weather, Season, and Celestial pages (including time modifiers) are retained
|
||||
in the book but have no effect on the Cave Age; each adds 100 base instability.
|
||||
The same policy applies to randomly selected Cave terrain and persists through
|
||||
save/reload. Cave biome selections remain limited to Cave and Nether biomes;
|
||||
surface-biome completion is not used for Cave terrain.
|
||||
|
||||
The separate **Floating Islands** feature page adds rare high-altitude island
|
||||
clusters above Standard, Amplified, Sky Islands, Flat, or Void terrain; it does
|
||||
not replace the terrain page. The feature is seeded by the Age and saved with the written
|
||||
|
||||
@@ -19,12 +19,12 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
|
||||
| `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture |
|
||||
| `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel |
|
||||
| `assets/rivencraft/textures/gui/ink_mixer_legacy.png` | `assets/mystcraft/gui/inkmixer.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Ink Mixer GUI uses the original 176×181 panel |
|
||||
| `assets/rivencraft/textures/block/ink_mixer_top.png`, `ink_mixer_side.png`, `ink_mixer_bottom.png` | `assets/mystcraft/textures/blocks/inkmixer_top.png`, `inkmixer_side.png`, `inkmixer_bottom.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Rivencraft supplies its own block model |
|
||||
| `assets/rivencraft/textures/block/inkwell_base.png`, `inkwell_cup.png`, `ink.png`, `ink_glass.png` | `assets/mystcraft/textures/entity/inkwell/` counterparts | Legacy 0.13.7.06 local extraction | Byte-identical copies used by the ported Ink Mixer geometry; the paper overlay uses vanilla white concrete instead of Legacy's black surface |
|
||||
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
|
||||
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
|
||||
| `assets/rivencraft/textures/gui/symbolcomponents.png` | `assets/mystcraft/textures/symbolcomponents.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Byte-identical shared 512×512 component sheet for client-side page composition |
|
||||
| Runtime-composed symbol pages (`SymbolPageTextures`) | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Replaces the six pre-generated symbol PNGs; layers four poem words on the shared page background and caches bounded client textures |
|
||||
| `assets/rivencraft/textures/block/crystal_block.png` | `assets/mystcraft/textures/blocks/crystal.png` | Legacy 0.13.7.06 local extraction | Exact 16×16 blue tile shared by Crystal Block and Budding Crystal models after removing a byte-identical duplicate |
|
||||
| `assets/rivencraft/textures/block/crystal_block.png` | `assets/mystcraft/textures/blocks/crystal.png` | Legacy 0.13.7.06 local extraction | Exact 16×16 blue tile shared by Crystal Block, Budding Crystal, and the non-dropping Age arrival platform; its former cloud sprite is removed |
|
||||
| `assets/rivencraft/textures/block/{small,medium,large}_crystal_bud.png`, `crystal_cluster.png` | Minecraft 1.21.1 `textures/block/{small,medium,large}_amethyst_bud.png`, `amethyst_cluster.png` | Minecraft 1.21.1 | Original 16×16 alpha masks and silhouettes preserved; hue shifted toward Legacy blue, with brighter saturation; exported as 8-bit RGBA (not indexed PNG) |
|
||||
| `assets/rivencraft/textures/item/crystal_shard.png` | Original sprite generated with OpenAI ImageGen from the Legacy crystal palette | 2026-10-05 | Cropped and nearest-neighbor scaled to 16×16 |
|
||||
| `assets/rivencraft/sounds/linking/link-fissure.ogg` | `assets/mystcraft/sounds/linking/link-fissure.ogg` | Legacy 0.13.7.06 local extraction | Namespace/path rename only; plays when a Star Fissure returns an entity home |
|
||||
@@ -32,6 +32,8 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
|
||||
| `assets/rivencraft/textures/block/book_receptacle.png` | `assets/mystcraft/textures/blocks/book_receptacle.png` | Legacy 0.13.7.06 local extraction | Byte-identical 16×16 face tile; modern block model recreates the Legacy thin slab |
|
||||
| `assets/rivencraft/textures/block/crystal_portal.png` and `.mcmeta` | `assets/mystcraft/textures/blocks/portal.png` and `.mcmeta` | Legacy 0.13.7.06 local extraction | Byte-identical texture and line-ending-normalized animation metadata, used when Immersive Portals is absent |
|
||||
| `assets/rivencraft/sounds/linking/link-portal.ogg` | `assets/mystcraft/sounds/linking/link-portal.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical portal-link sound |
|
||||
| `assets/rivencraft/sounds/linking/link-disarm.ogg` | `assets/mystcraft/sounds/linking/link-disarm.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical disarm-link sound; takes precedence over the portal sound as in Legacy |
|
||||
| `assets/rivencraft/sounds/linking/link-intra.ogg` | `assets/mystcraft/sounds/linking/link-intra.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical intra-link sound for Linking Books |
|
||||
|
||||
Mystcraft Legacy identifies XCompWiz as its project developer and includes the
|
||||
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -85,8 +89,9 @@ With optional JEI installed, search for the Ink Mixer category. It should show
|
||||
the base paper/ink/vial operation and separate entries for Gunpowder, Mushroom
|
||||
Stew, Experience Bottle, Ender Pearl, Feather, and Fire Charge,
|
||||
with their property chances. Black Dye and Clay Ball should not appear: the
|
||||
former had no working Legacy effect, while Generate Platform is redundant
|
||||
with default Age arrival. Without JEI, Rivencraft should still
|
||||
former had no working Legacy effect, while Clay Ball remains unsupported by
|
||||
the mixer. The creative Link Modifier can enable a single arrival platform for
|
||||
Linking Books; they do not create one by default. Without JEI, Rivencraft should still
|
||||
start and the mixer should work normally.
|
||||
|
||||
Put paper and an Ink Vial in the mixer. Hold Gunpowder on the cursor and
|
||||
|
||||
@@ -27,4 +27,4 @@ Implementation requirements: keep the mixer server-authoritative; persist mixtur
|
||||
|
||||
The built-in vanilla-item rows above are supported except black dye and clay balls. Legacy ignores black dye's empty effect key, so Rivencraft does not consume or advertise it in JEI. Clay Balls are omitted because Age arrival already builds a safe platform by default and a separate Generate Platform link modifier would be redundant. Right-clicking the Ink Mixer basin with a carried item adds one; left-clicking adds the carried stack. The mixture is persistent and its next panel is deterministic. The visual basin tooltip shows current probabilities. Only taking the virtual Link Panel consumes paper/ink and clears the mixture. The Link Panel's property component is copied to a Linking Book by the leather recipe or a Descriptive Book by the Binder. JEI's optional Ink Mixer category lists the base panel and each supported additive interaction with its per-property chance; Rivencraft does not require JEI to run.
|
||||
|
||||
Disarm and Maintain Momentum are applied by the server after confirming a safe destination. Disarm spawns each inventory stack as an item entity at the departure point, including for Descriptive Books. Intra Linking is required only for a Linking Book whose destination is in the player's current dimension; ordinary cross-dimensional Linking Books still work. The Binder treats an Intra Linking panel as an ordinary Link Panel and omits that flag from the resulting Descriptive Book, while retaining Disarm and Maintain Momentum. Automatic Return Links remain enabled under the existing configuration, as requested. The old Generate Platform bit remains readable on pre-0.4.7.1 test items but is neither rolled nor applied. Optional dust-tag mappings remain outside this built-in subset until their cross-mod tags and gameplay meaning are agreed.
|
||||
Disarm and Maintain Momentum are applied by the server after confirming a safe destination. Disarm spawns each inventory stack as an item entity at the departure point, including for Descriptive Books. Intra Linking is required only for a Linking Book whose destination is in the player's current dimension; ordinary cross-dimensional Linking Books still work. The Binder treats an Intra Linking panel as an ordinary Link Panel and omits that flag from the resulting Descriptive Book, while retaining Disarm and Maintain Momentum. Automatic Return Links remain enabled under the existing configuration, as requested. The Ink Mixer still does not roll Generate Platform, but since 0.6.7.1 the creative Link Modifier can explicitly enable it for Linking Books; their default remains off. Optional dust-tag mappings remain outside this built-in subset until their cross-mod tags and gameplay meaning are agreed.
|
||||
@@ -46,13 +46,13 @@ For a manual check, create a fresh surface Age and run `/rivencraft library near
|
||||
|
||||
Issue #75's comment calls this a despawning drop. The code differs subtly: [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L190-L224) explicitly cannot despawn, but takes one starvation damage every 10,000 ticks and damage while wet. It can eventually be destroyed if left unattended; a vanilla item entity's fixed five-minute timeout is not parity. A modern equivalent should retain the visible book and avoid silently deleting its Age identity at that timeout.
|
||||
|
||||
Legacy's travel path creates no automatic Return Link. Rivencraft currently defaults to `ON_CREATION` via `DescriptiveBookItem` and `descriptive_books.return_link_mode`. Per #75's comment, change the **new-config default** to `DISABLED` while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs must keep their explicit value. Handheld-book drop and Return Link default are tracked in [#85](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/85), not portal-frame logic.
|
||||
Legacy's travel path creates no automatic Return Link. Rivencraft now defaults to `DISABLED` in new server configs while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs keep their explicit value. Issue #85 adds the active-book drop only to hand-held Linking and Descriptive Book travel. On a successful link, the exact active stack becomes a dedicated `DroppedBookEntity` at the departure point, rendered with the same open `BookModel` and textures as the book stand; a rejected link restores it to the original hand. Stand and portal use leave their books in place. The entity has unlimited vanilla lifetime and loses one of its five item-health points every 10,000 loaded ticks, plus one per wet tick, matching Legacy's slow/wet decay. Unlike Legacy's custom entity, the modern book is picked up by ordinary contact, not sneaking with an empty hand, and remains subject to ordinary item hazards. Following is not yet a Rivencraft modifier, so no Following exception is exposed or silently simulated. Both hands can activate a book, whereas Legacy's explicit stack removal tested only its selected main-hand slot.
|
||||
|
||||
## Crafting, sounds, and progression
|
||||
|
||||
| Area | Legacy evidence and modern boundary |
|
||||
| --- | --- |
|
||||
| Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft already has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, and Linking Book recipes. #79 adds only missing recipes/processes; preserve vanilla-Lectern conversion, noncraftable Blank Pages, and optional JEI. |
|
||||
| Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, Linking Book, and Receptacle recipes. #79 keeps these data-driven and adds recipe-book unlocks for Crystal Block, Receptacle, and Ink Vial. The Legacy potion-based ink variant is omitted: in 1.21.1 a simple potion ingredient does not return its glass bottle, while the existing water-bucket recipe returns its bucket. The Legacy Folder/Portfolio use Rivencraft's Notebook; the old two-part Desk is one item; the old Lectern/Book Stand recipes use a vanilla Lectern converted in-world by adding a Rivencraft book or page. Blank Pages remain noncraftable and Link Panels remain outside the Desk. JEI automatically displays the JSON crafting recipes; its existing optional categories cover Mixer, Desk, and Binder processes. The lectern interaction intentionally has no JEI guide. With Create installed, an optional Ponder scene attached to the Receptacle item demonstrates frame construction and book insertion; the core mod still runs without Create. |
|
||||
| Sounds | [`Sounds`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/Sounds.java#L8-L26) registers pop, link, disarm, following, intra-link, fissure-link, portal-link, and meteor roar. The matching OGGs are in the local extraction. Rivencraft already carries link, fissure-link, and meteor roar. #80 should reuse and credit available Legacy files before creating alternatives. |
|
||||
| Advancements | [Legacy advancement JSONs](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/advancements) have inventory milestones for a symbol page, unlinked Linking Book, and Age Book; a Writing Desk trigger; a safe Age-travel trigger; and a hidden Quinn trigger. #81 should use modern server-owned criteria. Quinn has no established Rivencraft equivalent and should be documented or deliberately omitted, not awarded on preview. |
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Milestone 7 advancements and discovery (#81)
|
||||
|
||||
Rivencraft's advancement tab follows the visible progression in Mystcraft
|
||||
Legacy's `assets/mystcraft/advancements/`: Page, unwritten Linking Book,
|
||||
Writing Desk writing, Age Book, and safe Age travel. Titles such as *The Ending
|
||||
Has Not Yet Been Written*, *D'ni Ink*, and *The Art of Writing* reference Myst
|
||||
without requiring any assets from that game. The Legacy hidden Quinn trigger
|
||||
has no established Rivencraft action and is deliberately omitted.
|
||||
|
||||
`root`, `page`, and `linking_book` use vanilla server-side tick/inventory
|
||||
criteria. `link_panel`, `copy_page`, `bind_age`, `open_portal`, and `enter_age`
|
||||
use an impossible criterion awarded only after the server commits the
|
||||
corresponding action. The Age-travel award requires a successful book or portal
|
||||
link ending in a registered Rivencraft Age. Opening a book GUI, rendering a
|
||||
destination preview, viewing a recipe, or merely loading an Age cannot award
|
||||
it. Progress is stored by Minecraft per player and survives world restarts.
|
||||
|
||||
Vanilla recipe-book unlock advancements now cover the Writing Desk, Ink Mixer,
|
||||
Book Binder, and Notebook, in addition to the existing Ink Vial, Crystal
|
||||
Block, and Book Receptacle unlocks. The component-sensitive Linking Book
|
||||
crafting recipe remains a special recipe: its Link Panel requirement and the
|
||||
Writing Desk/Ink Mixer/Book Binder processes are explained by Rivencraft's
|
||||
optional JEI integration. No recipe or advancement requires JEI, Create, or
|
||||
Immersive Portals to be installed.
|
||||
|
||||
Manual Survival check: collect a Page and Linking Book; use the Ink Mixer to
|
||||
take a Link Panel; copy a Page at the Writing Desk; bind a Descriptive Book;
|
||||
enter its Age; activate a Crystal Portal. Confirm each advancement appears
|
||||
only after the completed action, not from an output preview or GUI opening.
|
||||
Repeat the actions with two players to confirm only the actor receives each
|
||||
award. Restart an existing save and confirm completed progress remains, then
|
||||
complete one previously unearned milestone. With JEI absent, the vanilla
|
||||
recipe book should still reveal standard crafting recipes as their ingredients
|
||||
are discovered; with JEI installed, the special writing guides should appear.
|
||||
|
||||
The automated Book Binder output-award scenario stalls in the GameTest storage
|
||||
runner, so it is intentionally omitted under the project test policy. For its
|
||||
manual check, open a ready Binder, observe that merely previewing the finished
|
||||
book does not award *A Book of Ages*, then take the book and confirm the award.
|
||||
Repeat by shift-clicking the result in a fresh Binder.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Milestone 7 compatibility review (#82)
|
||||
|
||||
Reviewed 2026-10-19 for Minecraft 1.21.1. The project now compiles against
|
||||
NeoForge 21.1.264 and advertises that as its minimum. The official
|
||||
[NeoForge Maven metadata](https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml)
|
||||
lists 21.1.264 as the newest 21.1 release. Its
|
||||
[changelog](https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.264/neoforge-21.1.264-changelog.txt)
|
||||
includes received split-packet size checks (21.1.260), network setup payload
|
||||
direction/decoding limits (21.1.261), ad-hoc channel identifier limits
|
||||
(21.1.262), and an initial attachment-sync fix (21.1.264). Those are concrete
|
||||
reasons to evaluate an upgrade, but do not by themselves prove Rivencraft and
|
||||
its optional integrations work on that loader.
|
||||
|
||||
**Decision: adopt 21.1.264 beginning with 0.6.6.2.** An override build
|
||||
succeeded, but its GameTest server repeated its storage-progress display
|
||||
without producing test results. That run was stopped; it is neither a pass nor
|
||||
evidence of a NeoForge defect. The player subsequently passed an in-game
|
||||
singleplayer test on 21.1.264 with the optional stack, then passed a dedicated
|
||||
server test including Immersive Portals travel to a Rivencraft Age. The
|
||||
dedicated server also reached the ready state with only the required mods
|
||||
before the full optional stack was installed. Both modes used Rivencraft
|
||||
0.6.6.1. The earlier 21.1.251 baseline passed 70 required GameTests for
|
||||
release V0.6.6.0.
|
||||
|
||||
| Dependency | Project baseline | Newest checked 1.21.1 release | Follow-up |
|
||||
| --- | --- | --- | --- |
|
||||
| Dynamic Dimensions AR Edition (required) | 0.9.1 | 0.9.1 | No newer release found on [CurseForge](https://www.curseforge.com/minecraft/mc-mods/dynamicdimension-ar-edition/files/all). |
|
||||
| Bad Packets (required) | neo-0.8.2 | neo-0.8.2 | No newer matching [Modrinth](https://modrinth.com/mod/badpackets/versions) release found. |
|
||||
| JEI (optional) | 19.57.0.449 at #82 review | 19.69.0.457 beta | Updated in #86 after client and dedicated GameTest passes; see [JEI review](MILESTONE_7_JEI_REVIEW.md). |
|
||||
| Create (optional) | 6.0.10 | 6.0.10 | No newer matching [Modrinth](https://modrinth.com/mod/create/versions) release found. Its jar bundles Ponder 1.0.82. |
|
||||
| Ponder (bundled with Create) | 1.0.82 | 1.0.87 library artifact | [Maven](https://maven.createmod.net/net/createmod/ponder/ponder-neoforge/maven-metadata.xml) has a newer artifact, but Create 6.0.10 still bundles 1.0.82. Do not treat this as a separately installed Create update. |
|
||||
| Immersive Portals (optional) | 6.0.7 | 6.0.7 | No newer matching [Modrinth](https://modrinth.com/mod/immersive-portals-neoforge/versions) release found. |
|
||||
| TerraBlender (optional) | 4.1.0.8 | 4.1.0.8 | No newer matching [Modrinth](https://modrinth.com/mod/terrablender/versions) release found. |
|
||||
| Serene Seasons (optional) | 10.1.0.8 at #82 review | 10.1.0.9 | Updated in #88 after the player's full seasonal matrix passed; see [Serene review](MILESTONE_7_SERENE_REVIEW.md). |
|
||||
| GlitchCore (Serene dependency) | 2.1.0.2 | 2.1.0.2 | No newer matching [Modrinth](https://modrinth.com/mod/glitchcore/versions) release found. |
|
||||
| Biomes O' Plenty (TerraBlender test addon) | 21.1.0.14 in user's Prism instance | 21.1.0.14 | No newer matching [Modrinth](https://modrinth.com/mod/biomes-o-plenty/versions) release found. |
|
||||
|
||||
The Modrinth version comparisons above were filtered to **NeoForge and
|
||||
Minecraft 1.21.1** on 2026-10-19. Create's bundled Ponder version was read
|
||||
from `META-INF/jarjar/metadata.json` in `create-1.21.1-6.0.10.jar`; the
|
||||
separate library-artifact comparison came from Create's Maven. Dynamic
|
||||
Dimensions was checked on CurseForge. Versions for other Minecraft lines and
|
||||
similarly named mods were excluded.
|
||||
|
||||
The full dedicated test stack contained Create/Ponder, Immersive Portals, JEI,
|
||||
TerraBlender with Biomes O' Plenty, and Serene Seasons with GlitchCore. The
|
||||
server loaded a new `rivencraft:age/test`, and its shutdown log confirms that
|
||||
all Overworld, Nether, End, and Age chunks were saved. The player explicitly
|
||||
approved 21.1.264 after that test. This does not independently certify every
|
||||
optional combination, the LEGACY preview fallback on a client without Create
|
||||
and Immersive Portals, or a full restart/rejoin cycle; keep those in the
|
||||
regression checklist. Serene's GameTest mock-player network limitation still
|
||||
requires real-client checks for seasonal behavior.
|
||||
@@ -0,0 +1,41 @@
|
||||
# JEI compatibility review (#86)
|
||||
|
||||
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
|
||||
now targets JEI 19.69.0.457 after the client's in-game acceptance check. JEI
|
||||
remains a `compileOnly` API and an opt-in
|
||||
`localRuntime` dependency for development; the published mod has no required
|
||||
JEI dependency.
|
||||
|
||||
The [official 1.21.1 NeoForge files](https://www.curseforge.com/minecraft/mc-mods/jei/files/all?page=1&pageSize=20&version=1.21.1)
|
||||
list 19.69.0.457 (beta, 2026-10-20) after 19.64.0.455 (release, 2026-10-16).
|
||||
The [19.69.0.457 Modrinth changelog](https://modrinth.com/mod/jei/versions?g=1.21.1&l=neoforge)
|
||||
has a published-jar bytecode compatibility check. Its preceding 19.69.0.456
|
||||
build changes ingredient search to Lucene and adds a runtime ingredient-search
|
||||
refresh API. This makes page search a required client check, even though the
|
||||
current Rivencraft plugin still compiles against the 457 API. The new Maven
|
||||
runtime artifact is much larger (about 7 MB versus 2.3 MB for 455); a successful
|
||||
compile alone is not evidence that its runtime plugin works.
|
||||
|
||||
Checks performed before changing the pinned dependency:
|
||||
|
||||
| Configuration | Result |
|
||||
| --- | --- |
|
||||
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true build` | Passed; existing JEI API uses compile with four deprecation warnings and no errors. |
|
||||
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true runGameTestServer` | Passed; dedicated GameTest server loaded JEI 19.69.0.457 and all 74 required tests passed. |
|
||||
| `./gradlew -Pjei_version=19.69.0.457 runGameTestServer` | Passed without JEI; all 74 required tests passed. |
|
||||
| Prism client with JEI 19.69.0.457 and Rivencraft 0.6.6.6 | Client joined an integrated world; JEI registered Rivencraft extra ingredients and started without a JEI-plugin exception. The player reported an in-game pass for guide display, page search, and recipe visibility. |
|
||||
|
||||
On first 457 launch, JEI migrated its old `lowMemorySlowSearchEnabled` config
|
||||
key and saved a backup of the original `jei-client.ini`; that warning was
|
||||
handled by JEI and did not block startup. No Rivencraft config was changed.
|
||||
|
||||
The in-game acceptance report covers the visible JEI integration. A dedicated
|
||||
GameTest server proves no server-side startup failure but does not exercise the
|
||||
JEI GUI; keep page search, recipes, blank-Page hiding, and the Ink Mixer,
|
||||
Writing Desk, and Book Binder guides in future upgrade checks. JEI is not
|
||||
bundled into or required by the published Rivencraft jar.
|
||||
|
||||
The final pinned `0.6.6.7` build passed. A repeat GameTest run from the pinned
|
||||
configuration stalled during test-world preparation before running any cases;
|
||||
it was stopped. The two successful pre-pin runs above used the same JEI version
|
||||
and code, with the dependency supplied by a Gradle property override.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Serene Seasons 10.1.0.9 compatibility review (#88)
|
||||
|
||||
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
|
||||
now compiles against [Serene Seasons 10.1.0.9](https://modrinth.com/mod/serene-seasons/version/pHEgQQUE)
|
||||
with [GlitchCore 2.1.0.2](https://modrinth.com/mod/glitchcore/version/S2TfWrZR).
|
||||
Both remain optional. The Serene update fixes a precipitation-mixin recursion;
|
||||
it does not introduce a new dynamic-dimension API.
|
||||
|
||||
The 10.1.0.8 and 10.1.0.9 bytecode for Rivencraft's version-sensitive
|
||||
`SeasonsConfig`, `SeasonHandler`, `SeasonSavedData`, and `SeasonTime` integration
|
||||
classes is identical. In particular, the dimension whitelist field, per-level
|
||||
saved season clock, and season update method have unchanged signatures and
|
||||
implementations. The adapter still writes the whitelist through GlitchCore's
|
||||
config API and sends Rivencraft's whitelist update to already connected clients.
|
||||
|
||||
Verification:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Build against 10.1.0.9 and GlitchCore 2.1.0.2 | Passed. |
|
||||
| Real Prism client with Serene 10.1.0.9, GlitchCore 2.1.0.2, and Rivencraft 0.6.6.7 | Both mods loaded and the player joined an Age. |
|
||||
| No Seasons, normal Seasons, and all four Eternal modes in new and saved Ages | Player reported the full in-game matrix passed. |
|
||||
| Reconnect, restart, and Overworld independence | Player reported the full in-game matrix passed. |
|
||||
| Isolated dedicated-server startup without Serene/GlitchCore | Reached `Done` on port 25568 with only required runtime mods, then saved and stopped. |
|
||||
| Isolated dedicated-server startup with Serene/GlitchCore | Reached `Done` on port 25569 with 10.1.0.9/2.1.0.2, then saved and stopped. |
|
||||
|
||||
Serene's sync packet is incompatible with GameTest mock players, so its
|
||||
in-game behavior must be checked with a real client and server. The seasonal
|
||||
test path in [SERENE_SEASONS_COMPATIBILITY.md](SERENE_SEASONS_COMPATIBILITY.md)
|
||||
remains the regression checklist for future upgrades. Rivencraft does not
|
||||
bundle or require Serene Seasons or GlitchCore.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Milestone 7 visual and audio polish (#80)
|
||||
|
||||
The source is the local Mystcraft 0.13.7.06 extraction under
|
||||
`/home/dnorberg/Documents/mystcraft-src/mystcraft-1.12.2-0.13.7.06/`.
|
||||
Reused textures and OGG files are recorded in [asset provenance](ASSET_PROVENANCE.md).
|
||||
The `dev images/book binder.png` and `dev images/ink mixer.png` screenshots
|
||||
are the Legacy model references used for this slice.
|
||||
|
||||
## Legacy comparison and modern boundary
|
||||
|
||||
- The Crystal Block, Book Receptacle, and 16×512 portal animation already use
|
||||
Legacy sprites. The Receptacle's 16×16×6 model already matches the Legacy
|
||||
slab. The fallback portal now uses Legacy's four-pixel-deep, double-faced
|
||||
`linkportal_ns.json` geometry, with the original animation; it declares a
|
||||
translucent render layer and portal particle texture. The block remains
|
||||
non-occluding. When Immersive Portals supplies a live view, the fallback
|
||||
model is hidden, while the crystal frame is drawn over the view.
|
||||
- The Age arrival platform now reuses the same Legacy crystal tile as Crystal
|
||||
Block. It is an opaque, occluding full block, while retaining its empty loot
|
||||
table and unobtainable pick-block behavior. It has glass-like 0.3 hardness.
|
||||
The old cloud sprite is unused and removed.
|
||||
- The Book Binder now includes the two inner braces and central press/book
|
||||
piece omitted by the earlier model, using the existing byte-identical
|
||||
Legacy textures. The Ink Mixer uses Legacy's `inkwell_model.json` geometry
|
||||
and four original inkwell textures. A thin white paper surface replaces the
|
||||
black paper appearance from the reference; the old simplified mixer tiles
|
||||
are no longer used. The half-alpha glass atlas is intentionally rendered on
|
||||
a cutout layer; its more opaque look passed the player's in-game check.
|
||||
- Legacy's link listener selects Disarm before a custom portal sound, then
|
||||
Following, Intra Linking, and ordinary Link. Rivencraft has no Following
|
||||
modifier yet, so it selects Disarm, portal, Intra Linking, or ordinary Link
|
||||
in that order. The Disarm and Intra OGGs are byte-identical Legacy copies.
|
||||
- Legacy sends 50 small neutral link particles in roughly a one-by-two-block
|
||||
region at both departure and arrival. Its fixed-function particle class is
|
||||
not portable to 1.21.1. Rivencraft uses a one-shot burst of 50 vanilla Cloud
|
||||
particles at both ends, emitted only after successful server-authoritative
|
||||
travel. There is no continuous portal-particle loop in the inspected Legacy
|
||||
`BlockLinkPortal` class, so the portal surface itself adds none.
|
||||
- The Legacy `textures/entity/linkbook.png` and `linking/pop.ogg` are inspected
|
||||
but not copied in this slice. The custom dropped Linkbook entity is part of
|
||||
#85; adding its assets before the entity exists would leave unused resources.
|
||||
Library walls, shelves, chest, and webs use vanilla blocks in the template,
|
||||
so there are no separate library art files to port.
|
||||
|
||||
## Manual visual/audio check
|
||||
|
||||
Use the same bound book in a held use and a Crystal Portal. Confirm the link
|
||||
particles and audio play at both ends, and the portal landing matches the book.
|
||||
Repeat with Disarm and Intra Linking panels to check the distinct sounds.
|
||||
Compare the fallback portal, crystal frame, and Receptacle with Immersive
|
||||
Portals absent; verify the animated surface is visible from both sides and
|
||||
does not expose caves or other geometry through solid blocks. With Immersive
|
||||
Portals installed, confirm the live view replaces only the fallback surface,
|
||||
the frame draws in front, and no fallback texture covers the live view.
|
||||
Create/Ponder is optional and must not affect either rendering path.
|
||||
Check a freshly generated arrival platform from above and below: the crystal
|
||||
tile should be opaque, break about as quickly as glass, and drop no block item.
|
||||
Compare the Book Binder press and Ink Mixer cup/glass against the `dev images`
|
||||
references; the mixer paper should appear white, and neither block should
|
||||
show missing-texture checkerboards or see-through-world artifacts.
|
||||
|
||||
The player reported that the in-game visual and audio checks pass on 2026-10-19,
|
||||
including acceptance of the cutout glass appearance and current block sounds.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,384 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ being captured in the preview.
|
||||
|
||||
## Supported combinations and upstream limits
|
||||
|
||||
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.251, Create
|
||||
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.264, Create
|
||||
6.0.10, and Immersive Portals 6.0.7. Core Age generation and the `LEGACY`
|
||||
backend run without Create or Immersive Portals; the headless GameTest server
|
||||
uses that dependency-free path.
|
||||
@@ -66,8 +66,11 @@ unstable in a particular graphics or mod configuration.
|
||||
|
||||
For preview-related releases:
|
||||
|
||||
1. Run `./gradlew runGameTestServer -PwithArchivedGameTests=true` to verify the complete selection matrix,
|
||||
core Age creation, persistence, and travel without optional renderers.
|
||||
1. Run `./gradlew runGameTestServer` for the core selection matrix, Age
|
||||
creation, persistence, and travel without optional renderers. Add the
|
||||
relevant archives from [GameTest guidance](GAMETESTS.md); if a scenario
|
||||
stalls, stop it and hand that exact check to a real client rather than
|
||||
treating the interrupted run as a pass.
|
||||
2. Open held Linking and Descriptive Books once with each available backend.
|
||||
3. Open both book types from a Book Stand and confirm Explore is disabled.
|
||||
4. With Immersive Portals, enter and leave Explore, travel between dimensions,
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -20,7 +20,7 @@ Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalo
|
||||
|
||||
`poem` is optional, client-safe artwork metadata: exactly four alphabetic words, ordered top, right, bottom, left. The client layers their glyphs from the shared Legacy component sheet at runtime. GUI thumbnails use the full transparent artwork over their page backgrounds; item icons use the same artwork reduced from 16 to 13 pixels across, over the page texture. Unregistered words use a stable generated component combination; when a symbol has no poem, the six original built-in IDs retain their Legacy poems and other IDs produce four stable fallback words. This affects appearance only, never Age generation. Generated images are cached up to 256 entries and discarded on resource reload.
|
||||
|
||||
The Writing Desk's 88 collection tabs use Legacy's number components, chiefly the bottom row of `symbolcomponents.png`, rather than word sigils. Legacy's `ModWords.constructNumber` combines four unit marks (1–4) with four five-step marks (5–20) to form 1–24 and uses a separate zero mark. Rivencraft extends these marks positionally in base 25 for tabs 25–88 (for example, 25 is 1 followed by 0). Hovering still reveals the decimal number for accessibility. The former arbitrary-word glyph generator remains useful for rendering text: on page-free Descriptive Books, the generated Age description now appears as a sequence of layered word glyphs instead of Minecraft's enchantment font. The Age name stays readable.
|
||||
The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Legacy's `ModWords.constructNumber` defines 0 as component 1 and 1–24 by overlaying a unit mark (1–4) and a five-step mark (5–20) from the bottom row of `symbolcomponents.png` in the *same square*. Legacy's Writing Desk has 25 tabs (0–24) and scrolls its four-tab window **one tab at a time**. Thus the supplied Legacy screenshots labeled 1–4, 5–8, and so on show overlapping windows, not 88 distinct numerals. Rivencraft retains its previously requested four-at-a-time navigation and 88 tabs. Its additional tab numbers use positional base 25: each place is an overlaid Legacy digit, with the places side by side (25 = 1,0). The two glyphs are sized and positioned to fit the tab. Hovering reveals the decimal tab index for accessibility. The generated-word mechanism renders page-free Descriptive Book text as layered glyphs instead of Minecraft's enchantment font. The Age name stays readable.
|
||||
|
||||
| Role | Value type and allowed values |
|
||||
| --- | --- |
|
||||
@@ -32,9 +32,10 @@ The Writing Desk's 88 collection tabs use Legacy's number components, chiefly th
|
||||
| `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. |
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ minecraft_version=1.21.1
|
||||
# as they do not follow standard versioning conventions.
|
||||
minecraft_version_range=[1.21.1]
|
||||
# The Neo version must agree with the Minecraft version to get a valid artifact
|
||||
neo_version=21.1.251
|
||||
neo_version=21.1.264
|
||||
ponder_version=1.0.82
|
||||
dynamic_dimensions_file_id=7707789
|
||||
badpackets_version=0.8.2
|
||||
jei_version=19.57.0.449
|
||||
jei_version=19.69.0.457
|
||||
# The loader version range can only use the major version of FML as bounds
|
||||
loader_version_range=[1,)
|
||||
|
||||
@@ -36,10 +36,10 @@ 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.3.4
|
||||
mod_version=1.0.1.1
|
||||
immersive_portals_version=6.0.7
|
||||
terrablender_version=4.1.0.8
|
||||
serene_seasons_version_id=B6nTjh4b
|
||||
serene_seasons_version_id=pHEgQQUE
|
||||
glitchcore_version_id=S2TfWrZR
|
||||
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
||||
# This should match the base package used for the mod sources.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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. Descriptive Book
|
||||
travel and named Linking Book binding/travel work as limited probes. 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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,87 @@
|
||||
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/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/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 'data/rivencraft/loot_table/blocks/crystal_block.json'
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
tasks.named('runServer').configure {
|
||||
standardInput = System.in
|
||||
}
|
||||
@@ -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.2-alpha.1
|
||||
@@ -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,67 @@
|
||||
package net.dnorberg.rivencraft;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
|
||||
import net.dnorberg.rivencraft.age.PortAgeCommands;
|
||||
import net.dnorberg.rivencraft.age.AgeCatalogSync;
|
||||
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
|
||||
import net.dnorberg.rivencraft.item.LinkBookTarget;
|
||||
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
||||
import net.dnorberg.rivencraft.network.PortBookNetworking;
|
||||
import net.minecraft.core.component.DataComponentType;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||
import net.minecraft.world.item.BlockItem;
|
||||
import net.minecraft.world.item.Item;
|
||||
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.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));
|
||||
|
||||
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 DeferredItem<Item> CRYSTAL_SHARD = ITEMS.registerSimpleItem("crystal_shard");
|
||||
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 Rivencraft(IEventBus modEventBus) {
|
||||
BLOCKS.register(modEventBus);
|
||||
ITEMS.register(modEventBus);
|
||||
COMPONENTS.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,269 @@
|
||||
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();
|
||||
}
|
||||
|
||||
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,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,26 @@
|
||||
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()
|
||||
|| !event.getItemStack().is(Rivencraft.LINKING_BOOK.get())) return;
|
||||
event.setCanceled(true);
|
||||
event.setCancellationResult(InteractionResult.SUCCESS);
|
||||
Minecraft.getInstance().setScreenAndShow(new PortLinkingBookScreen(
|
||||
event.getHand(), event.getItemStack().copy()));
|
||||
}
|
||||
}
|
||||
@@ -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,52 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.age.PortAgeTravel;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
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;
|
||||
|
||||
/** First 26.3 bound-book slice; authoring and the book GUI still need porting. */
|
||||
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) {
|
||||
ItemStack stack = player.getItemInHand(hand);
|
||||
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
if (id == null) {
|
||||
if (player instanceof ServerPlayer serverPlayer) {
|
||||
serverPlayer.sendOverlayMessage(Component.literal("Unbound Descriptive Book; use the port's Age book probe"));
|
||||
}
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
if (player instanceof ServerPlayer serverPlayer) {
|
||||
String failure = PortAgeTravel.enter(serverPlayer, id);
|
||||
serverPlayer.sendOverlayMessage(Component.literal(failure == null ? "Entered " + id : failure));
|
||||
}
|
||||
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,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,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,57 @@
|
||||
package net.dnorberg.rivencraft.network;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
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.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;
|
||||
|
||||
/** 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);
|
||||
});
|
||||
}
|
||||
|
||||
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 InteractionHand hand(boolean offhand) {
|
||||
return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
|
||||
}
|
||||
}
|
||||
@@ -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,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,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "rivencraft:block/crystal_block"
|
||||
}
|
||||
}
|
||||
@@ -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,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "rivencraft:item/linking_book_bound"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"item.rivencraft.crystal_shard": "Crystal Shard",
|
||||
"item.rivencraft.descriptive_book": "Descriptive Book",
|
||||
"item.rivencraft.linking_book": "Linking Book",
|
||||
"block.rivencraft.crystal_block": "Crystal Block"
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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();
|
||||
@@ -54,6 +56,7 @@ public final class Rivencraft {
|
||||
ModBiomeSources.register(modEventBus);
|
||||
ModChunkGenerators.register(modEventBus);
|
||||
ModBlocks.register(modEventBus);
|
||||
net.dnorberg.rivencraft.registry.ModEntities.register(modEventBus);
|
||||
ModBlockEntities.register(modEventBus);
|
||||
ModItems.register(modEventBus);
|
||||
ModMenus.register(modEventBus);
|
||||
@@ -81,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);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package net.dnorberg.rivencraft.advancement;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
/** Awards action-only progression after the server has committed the action. */
|
||||
public final class ProgressionAdvancements {
|
||||
public static final String LINK_PANEL = "link_panel";
|
||||
public static final String COPY_PAGE = "copy_page";
|
||||
public static final String BIND_AGE = "bind_age";
|
||||
public static final String OPEN_PORTAL = "open_portal";
|
||||
public static final String ENTER_AGE = "enter_age";
|
||||
|
||||
private ProgressionAdvancements() {}
|
||||
|
||||
public static void award(ServerPlayer player, String path) {
|
||||
var advancement = player.getServer().getAdvancements().get(
|
||||
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
|
||||
if (advancement != null) player.getAdvancements().award(advancement, "completed");
|
||||
}
|
||||
|
||||
public static void awardAgeTravel(ServerPlayer player) {
|
||||
if (AgeRegistrySavedData.get(player.serverLevel()).findByDimension(
|
||||
player.serverLevel().dimension().location()).isPresent()) {
|
||||
award(player, ENTER_AGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
@@ -35,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;
|
||||
@@ -43,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; }
|
||||
@@ -63,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);
|
||||
@@ -76,7 +79,7 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
||||
}
|
||||
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||
Direction face = context.getClickedFace();
|
||||
if (face == Direction.DOWN || !context.getLevel().getBlockState(
|
||||
if (!context.getLevel().getBlockState(
|
||||
context.getClickedPos().relative(face.getOpposite())).is(ModBlocks.CRYSTAL_BLOCK.get())) return null;
|
||||
return defaultBlockState().setValue(FACING, face);
|
||||
}
|
||||
@@ -96,7 +99,11 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
||||
LinkTarget destination = stack.is(ModItems.LINKING_BOOK.get())
|
||||
? stack.get(ModDataComponents.LINK_TARGET.get())
|
||||
: player instanceof ServerPlayer serverPlayer
|
||||
? AgeDimensionService.resolve(serverPlayer, stack.get(ModDataComponents.AGE_REFERENCE.get())).orElse(null)
|
||||
? AgeDimensionService.resolve(serverPlayer, stack.get(ModDataComponents.AGE_REFERENCE.get()),
|
||||
net.dnorberg.rivencraft.writing.OrderedWriting.EMPTY,
|
||||
!stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(),
|
||||
net.dnorberg.rivencraft.book.LinkProperties.NONE)
|
||||
.has(net.dnorberg.rivencraft.book.LinkProperties.DISABLE_PLATFORM)).orElse(null)
|
||||
: null;
|
||||
if (destination == null || server.getServer().getLevel(destination.location().dimension()) == null) {
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
|
||||
@@ -117,6 +124,8 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
||||
server, pos, plan, destination));
|
||||
}
|
||||
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.OPEN_PORTAL);
|
||||
return ItemInteractionResult.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -146,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));
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.LevelReader;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Solid footing for Age arrivals, rendered as a translucent cloud. */
|
||||
/** Solid crystal-textured footing for Age arrivals that cannot be collected. */
|
||||
public final class CloudPlatformBlock extends Block {
|
||||
public CloudPlatformBlock(Properties properties) {
|
||||
super(properties);
|
||||
@@ -18,19 +16,4 @@ public final class CloudPlatformBlock extends Block {
|
||||
public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean skipRendering(BlockState state, BlockState adjacent, Direction side) {
|
||||
return adjacent.is(this) || super.skipRendering(state, adjacent, side);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean propagatesSkylightDown(BlockState state, BlockGetter level, BlockPos pos) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getLightBlock(BlockState state, BlockGetter level, BlockPos pos) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,14 @@ package net.dnorberg.rivencraft.block;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
|
||||
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
@@ -55,17 +53,16 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
|
||||
@Override protected boolean canBeReplaced(BlockState state, Fluid fluid) { return false; }
|
||||
|
||||
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) {
|
||||
if (!(level instanceof ServerLevel server) || !(entity instanceof ServerPlayer player)) return;
|
||||
if (!(level instanceof ServerLevel server)) return;
|
||||
long now = server.getServer().getTickCount();
|
||||
if (PortalTravelCooldown.blocked(player, now)) return;
|
||||
if (PortalTravelCooldown.blocked(entity, now)) return;
|
||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|
||||
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
|
||||
ItemStack book = holder.getBook();
|
||||
if (book.isEmpty()) return;
|
||||
PortalTravelCooldown.mark(player, now);
|
||||
if (book.is(ModItems.LINKING_BOOK.get())) LinkingBookItem.usePortalBook(player, book);
|
||||
else if (book.is(ModItems.DESCRIPTIVE_BOOK.get())) DescriptiveBookItem.usePortalBook(player, book);
|
||||
PortalTravelCooldown.mark(entity, now);
|
||||
PortalTravelQueue.enqueue(entity, server, pos, portal.receptacle(), book, now);
|
||||
}
|
||||
|
||||
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
|
||||
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.Containers;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.SimpleMenuProvider;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||
import net.minecraft.world.level.block.RenderShape;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.DirectionProperty;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
|
||||
public final class LinkModifierBlock extends BaseEntityBlock {
|
||||
public static final MapCodec<LinkModifierBlock> CODEC = simpleCodec(LinkModifierBlock::new);
|
||||
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
|
||||
|
||||
public LinkModifierBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
|
||||
}
|
||||
|
||||
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block,
|
||||
BlockState> builder) { builder.add(FACING); }
|
||||
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
|
||||
}
|
||||
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new LinkModifierBlockEntity(pos, state);
|
||||
}
|
||||
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||
Player player, BlockHitResult hit) {
|
||||
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
|
||||
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
|
||||
serverPlayer.openMenu(new SimpleMenuProvider(
|
||||
(id, inventory, ignored) -> new LinkModifierMenu(id, inventory, modifier),
|
||||
Component.translatable("container.rivencraft.link_modifier")),
|
||||
buffer -> buffer.writeBlockPos(pos));
|
||||
}
|
||||
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||
}
|
||||
@Override protected void onRemove(BlockState state, Level level, BlockPos pos,
|
||||
BlockState replacement, boolean moving) {
|
||||
if (!state.is(replacement.getBlock()) && !level.isClientSide
|
||||
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
|
||||
Containers.dropContents(level, pos, modifier);
|
||||
}
|
||||
super.onRemove(state, level, pos, replacement, moving);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
@@ -96,7 +97,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
||||
case COVER -> validCover(stack);
|
||||
case LINK_PANEL -> stack.is(ModItems.PAGE.get())
|
||||
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL;
|
||||
case NOTEBOOK -> stack.is(ModItems.NOTEBOOK.get());
|
||||
case NOTEBOOK -> CollationFolderItem.isCollection(stack);
|
||||
default -> slot >= FIRST_PAGE && slot < SIZE && validPage(stack);
|
||||
};
|
||||
}
|
||||
@@ -296,7 +297,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
||||
|
||||
/** Import a Notebook from either its stored slot or the player's cursor. */
|
||||
public ItemStack importNotebookFromStack(ItemStack notebook, int index) {
|
||||
if (!notebook.is(ModItems.NOTEBOOK.get()) || notebook.getCount() != 1) return ItemStack.EMPTY;
|
||||
if (!CollationFolderItem.isCollection(notebook) || notebook.getCount() != 1) return ItemStack.EMPTY;
|
||||
List<ItemStack> pages = notebook.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||
if (pages.isEmpty()) return ItemStack.EMPTY;
|
||||
int end = nextPageIndex();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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;
|
||||
@@ -20,8 +23,10 @@ import net.neoforged.fml.ModList;
|
||||
public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
private static final String BOOK_TAG = "Book";
|
||||
private static final String DESTINATION_TAG = "Destination";
|
||||
private static final String SKY_COLOR_TAG = "SkyColor";
|
||||
private ItemStack book = ItemStack.EMPTY;
|
||||
private LinkTarget destination;
|
||||
private int skyColor = 0xFFFFFF;
|
||||
private boolean restoreView = true;
|
||||
|
||||
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
|
||||
@@ -30,6 +35,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
|
||||
public ItemStack getBook() { return book; }
|
||||
public LinkTarget destination() { return destination; }
|
||||
public int skyColor() { return skyColor; }
|
||||
|
||||
public void setBook(ItemStack stack) {
|
||||
book = stack.copyWithCount(1);
|
||||
@@ -38,21 +44,46 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
|
||||
public void setDestination(LinkTarget target) {
|
||||
destination = target;
|
||||
skyColor = 0xFFFFFF;
|
||||
if (target != null && level instanceof ServerLevel server) {
|
||||
ServerLevel targetLevel = server.getServer().getLevel(target.location().dimension());
|
||||
if (targetLevel != null) skyColor = AgeEnvironmentCompiler.previewSkyColor(
|
||||
targetLevel, target.location().pos()) & 0xFFFFFF;
|
||||
}
|
||||
sync();
|
||||
}
|
||||
|
||||
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;
|
||||
sync();
|
||||
skyColor = 0xFFFFFF;
|
||||
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
|
||||
@@ -63,6 +94,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
: ItemStack.EMPTY;
|
||||
destination = tag.contains(DESTINATION_TAG)
|
||||
? LinkTarget.CODEC.parse(NbtOps.INSTANCE, tag.get(DESTINATION_TAG)).result().orElse(null) : null;
|
||||
skyColor = tag.contains(SKY_COLOR_TAG) ? tag.getInt(SKY_COLOR_TAG) & 0xFFFFFF : 0xFFFFFF;
|
||||
restoreView = true;
|
||||
}
|
||||
|
||||
@@ -72,6 +104,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
if (!book.isEmpty()) tag.put(BOOK_TAG, book.save(registries));
|
||||
if (destination != null) LinkTarget.CODEC.encodeStart(NbtOps.INSTANCE, destination)
|
||||
.result().ifPresent(encoded -> tag.put(DESTINATION_TAG, encoded));
|
||||
if (destination != null) tag.putInt(SKY_COLOR_TAG, skyColor);
|
||||
}
|
||||
|
||||
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
|
||||
@@ -85,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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.ContainerHelper;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** One persisted book slot; edits are performed by the server menu. */
|
||||
public final class LinkModifierBlockEntity extends BlockEntity implements Container {
|
||||
private NonNullList<ItemStack> items = NonNullList.withSize(1, ItemStack.EMPTY);
|
||||
|
||||
public LinkModifierBlockEntity(BlockPos pos, BlockState state) {
|
||||
super(ModBlockEntities.LINK_MODIFIER.get(), pos, state);
|
||||
}
|
||||
|
||||
@Override public int getContainerSize() { return 1; }
|
||||
@Override public boolean isEmpty() { return items.getFirst().isEmpty(); }
|
||||
@Override public ItemStack getItem(int slot) { return items.get(slot); }
|
||||
@Override public ItemStack removeItem(int slot, int amount) {
|
||||
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
|
||||
if (!removed.isEmpty()) setChanged();
|
||||
return removed;
|
||||
}
|
||||
@Override public ItemStack removeItemNoUpdate(int slot) {
|
||||
ItemStack removed = ContainerHelper.takeItem(items, slot);
|
||||
if (!removed.isEmpty()) setChanged();
|
||||
return removed;
|
||||
}
|
||||
@Override public void setItem(int slot, ItemStack stack) {
|
||||
items.set(slot, stack.copyWithCount(Math.min(1, stack.getCount())));
|
||||
setChanged();
|
||||
}
|
||||
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||
return slot == 0 && (stack.is(ModItems.LINKING_BOOK.get())
|
||||
|| stack.is(ModItems.DESCRIPTIVE_BOOK.get()));
|
||||
}
|
||||
@Override public boolean stillValid(Player player) {
|
||||
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
|
||||
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
|
||||
worldPosition.getZ() + 0.5) <= 64.0;
|
||||
}
|
||||
@Override public void clearContent() {
|
||||
items = NonNullList.withSize(1, ItemStack.EMPTY);
|
||||
setChanged();
|
||||
}
|
||||
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
super.loadAdditional(tag, registries);
|
||||
items = NonNullList.withSize(1, ItemStack.EMPTY);
|
||||
ContainerHelper.loadAllItems(tag, items, registries);
|
||||
}
|
||||
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
super.saveAdditional(tag, registries);
|
||||
ContainerHelper.saveAllItems(tag, items, registries);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||
import net.dnorberg.rivencraft.writing.InkVialExchange;
|
||||
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
@@ -87,7 +88,7 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
|
||||
worldPosition.getZ() + 0.5) <= 64.0;
|
||||
}
|
||||
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||
if (slot < COLLECTIONS) return stack.is(ModItems.NOTEBOOK.get())
|
||||
if (slot < COLLECTIONS) return CollationFolderItem.isCollection(stack)
|
||||
&& stack.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
|
||||
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|
||||
|| stack.is(ModItems.PAGE.get())
|
||||
|
||||
@@ -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,12 +23,14 @@ 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"
|
||||
);
|
||||
public static final int CURRENT_GENERATION_VERSION = 16;
|
||||
public static final int LEGACY_GENERATOR_CUTOFF = 16;
|
||||
public static final int CURRENT_GENERATION_VERSION = AgeSkyProfile.RANDOM_SKY_GENERATION_VERSION;
|
||||
|
||||
public AgeDefinition {
|
||||
Objects.requireNonNull(id, "id");
|
||||
@@ -38,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");
|
||||
@@ -87,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);
|
||||
}
|
||||
@@ -116,6 +129,12 @@ 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. */
|
||||
public boolean caveTerrain() {
|
||||
return AgeBiomeSelection.isCave(plan == null
|
||||
? AgeRuntimeProfiles.resolve(generationProfile) : plan.runtimeProfile());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Retires an exact Age and moves its unloaded region data out of the live world. */
|
||||
public final class AgeDeletionService {
|
||||
public enum Result { RETIRED, ALREADY_DEAD, OCCUPIED, UNLOAD_FAILED, STORAGE_FAILED }
|
||||
|
||||
private AgeDeletionService() { }
|
||||
|
||||
public static Result retire(MinecraftServer server, AgeDefinition age) {
|
||||
AgeRegistrySavedData registry = AgeRegistrySavedData.get(server.overworld());
|
||||
if (registry.isDead(age.id())) return Result.ALREADY_DEAD;
|
||||
var level = server.getLevel(AgeDimensionService.dimensionKey(age));
|
||||
if (level != null && !level.players().isEmpty()) return Result.OCCUPIED;
|
||||
if (AgeDimensionService.unload(server, age) == AgeDimensionService.UnloadResult.FAILED)
|
||||
return Result.UNLOAD_FAILED;
|
||||
|
||||
Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
|
||||
Path ageRoot = world.resolve("dimensions/rivencraft/age").normalize();
|
||||
Path data = world.resolve("dimensions")
|
||||
.resolve(age.dimensionId().getNamespace())
|
||||
.resolve(age.dimensionId().getPath()).normalize();
|
||||
// Exact Rivencraft Age only; never move the shared age/ directory.
|
||||
if (!data.startsWith(ageRoot) || data.equals(ageRoot)
|
||||
|| !"rivencraft".equals(age.dimensionId().getNamespace())
|
||||
|| !age.dimensionId().getPath().startsWith("age/")) return Result.STORAGE_FAILED;
|
||||
try {
|
||||
if (Files.isSymbolicLink(world.resolve("dimensions"))
|
||||
|| Files.isSymbolicLink(world.resolve("dimensions/rivencraft"))
|
||||
|| Files.isSymbolicLink(ageRoot) || Files.isSymbolicLink(data)) return Result.STORAGE_FAILED;
|
||||
if (Files.exists(data, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Path archive = world.getParent().resolve(world.getFileName() + "-retired-ages")
|
||||
.resolve(age.id().toString());
|
||||
if (Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) return Result.STORAGE_FAILED;
|
||||
Files.createDirectories(archive.getParent());
|
||||
try { Files.move(data, archive, StandardCopyOption.ATOMIC_MOVE); }
|
||||
catch (AtomicMoveNotSupportedException exception) { Files.move(data, archive); }
|
||||
Rivencraft.LOGGER.warn("Retired Age {} data moved from {} to {}", age.id(), data, archive);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
Rivencraft.LOGGER.error("Cannot archive data for Age {}", age.id(), exception);
|
||||
return Result.STORAGE_FAILED;
|
||||
}
|
||||
registry.markDead(age.id());
|
||||
return Result.RETIRED;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -74,6 +75,7 @@ public final class AgeDimensionService {
|
||||
DynamicDimensionLoadCallback.register((server, loader) -> {
|
||||
ServerLevel overworld = server.overworld();
|
||||
for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) {
|
||||
if (AgeRegistrySavedData.get(overworld).isDead(age.id())) continue;
|
||||
if (!canLoadProfile(server, age)) continue;
|
||||
ServerLevel level = loader.loadDynamicDimension(
|
||||
age.dimensionId(),
|
||||
@@ -83,6 +85,7 @@ public final class AgeDimensionService {
|
||||
if (level != null) {
|
||||
applyCelestialClock(level, age);
|
||||
SereneSeasonsCompatibility.registerAge(server, age);
|
||||
RuinsCompatibility.registerAge(age);
|
||||
}
|
||||
}
|
||||
synchronizeImmersivePortals(server);
|
||||
@@ -95,8 +98,19 @@ public final class AgeDimensionService {
|
||||
|
||||
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
|
||||
OrderedWriting writing) {
|
||||
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(player.serverLevel(), reference, writing);
|
||||
ServerLevel level = ensureLoaded(player.getServer(), definition);
|
||||
return resolve(player, reference, writing, true);
|
||||
}
|
||||
|
||||
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
|
||||
OrderedWriting writing, boolean generatePlatform) {
|
||||
return resolve(player.serverLevel(), reference, writing, generatePlatform);
|
||||
}
|
||||
|
||||
public static Optional<LinkTarget> resolve(ServerLevel source, AgeReference reference,
|
||||
OrderedWriting writing, boolean generatePlatform) {
|
||||
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(source, reference, writing);
|
||||
if (AgeRegistrySavedData.get(source).isDead(definition.id())) return Optional.empty();
|
||||
ServerLevel level = ensureLoaded(source.getServer(), definition);
|
||||
if (level == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
@@ -104,7 +118,8 @@ public final class AgeDimensionService {
|
||||
BlockPos spawn = definition.spawn();
|
||||
if (spawn == null) {
|
||||
applyInitialCelestialPhase(level, definition);
|
||||
spawn = createSafeSpawn(level, definition);
|
||||
spawn = createSafeSpawn(level, definition, generatePlatform);
|
||||
if (spawn == null) return Optional.empty();
|
||||
if (AgeFeatureSelection.resolved(definition.plan()).stream()
|
||||
.anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES))
|
||||
|| definition.randomFeatures().stream()
|
||||
@@ -122,11 +137,13 @@ public final class AgeDimensionService {
|
||||
}
|
||||
|
||||
public static ServerLevel ensureLoaded(MinecraftServer server, AgeDefinition definition) {
|
||||
if (AgeRegistrySavedData.get(server.overworld()).isDead(definition.id())) return null;
|
||||
ResourceKey<Level> key = dimensionKey(definition);
|
||||
ServerLevel existing = server.getLevel(key);
|
||||
if (existing != null) {
|
||||
applyCelestialClock(existing, definition);
|
||||
SereneSeasonsCompatibility.registerAge(server, definition);
|
||||
RuinsCompatibility.registerAge(definition);
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -143,6 +160,7 @@ public final class AgeDimensionService {
|
||||
} else {
|
||||
applyCelestialClock(loaded, definition);
|
||||
SereneSeasonsCompatibility.registerAge(server, definition);
|
||||
RuinsCompatibility.registerAge(definition);
|
||||
synchronizeImmersivePortals(server);
|
||||
}
|
||||
return loaded;
|
||||
@@ -170,6 +188,10 @@ public final class AgeDimensionService {
|
||||
}
|
||||
|
||||
private static void applyCelestialClock(ServerLevel level, AgeDefinition age) {
|
||||
if (age.caveTerrain()) {
|
||||
level.setDayTimePerTick(1.0F);
|
||||
return;
|
||||
}
|
||||
if (age.traits().hasStoppedCelestialCycle()) {
|
||||
// NeoForge rejects an exact zero rate. The smallest positive float
|
||||
// never advances the integer clock during a practical world
|
||||
@@ -198,6 +220,7 @@ public final class AgeDimensionService {
|
||||
}
|
||||
|
||||
private static void applyInitialCelestialPhase(ServerLevel level, AgeDefinition age) {
|
||||
if (age.caveTerrain()) return;
|
||||
long phaseTime = (long)(age.traits().celestialPhase() * Level.TICKS_PER_DAY);
|
||||
if (AgeEnvironmentCompiler.hasWrittenPhase(age.plan())) {
|
||||
// Legacy's Nadir is midnight, while vanilla day time zero is sunrise.
|
||||
@@ -216,21 +239,24 @@ public final class AgeDimensionService {
|
||||
return ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, definition.dimensionId());
|
||||
}
|
||||
|
||||
private static BlockPos createSafeSpawn(ServerLevel level, AgeDefinition age) {
|
||||
private static BlockPos createSafeSpawn(ServerLevel level, AgeDefinition age, boolean generatePlatform) {
|
||||
BlockPos floorCenter;
|
||||
if (age.generationProfile().equals(AgeGenerationProfiles.CAVE)) {
|
||||
if (age.caveTerrain()) {
|
||||
// Cave Ages are enclosed. Searching from the world ceiling would
|
||||
// select or expose their roof, so begin with the floor beneath a
|
||||
// player standing at Y=64 and search downward from there.
|
||||
floorCenter = highestFootedSurface(level, CAVE_SPAWN_SEARCH_Y - 1)
|
||||
.orElseGet(() -> fallbackSpawnFloor(level));
|
||||
.orElseGet(() -> generatePlatform ? fallbackSpawnFloor(level) : null);
|
||||
} else if (isNetherStyle(level)) {
|
||||
floorCenter = netherSpawnFloor(level);
|
||||
floorCenter = generatePlatform ? netherSpawnFloor(level)
|
||||
: highestFootedSurface(level, CAVE_SPAWN_SEARCH_Y - 1).orElse(null);
|
||||
} else {
|
||||
floorCenter = highestFootedSurface(level).orElseGet(() -> fallbackSpawnFloor(level));
|
||||
floorCenter = highestFootedSurface(level).orElseGet(() -> generatePlatform ? fallbackSpawnFloor(level) : null);
|
||||
}
|
||||
|
||||
for (int x = -PLATFORM_RADIUS; x <= PLATFORM_RADIUS; x++) {
|
||||
if (floorCenter == null) return null;
|
||||
|
||||
if (generatePlatform) for (int x = -PLATFORM_RADIUS; x <= PLATFORM_RADIUS; x++) {
|
||||
for (int z = -PLATFORM_RADIUS; z <= PLATFORM_RADIUS; z++) {
|
||||
BlockPos floor = floorCenter.offset(x, 0, z);
|
||||
level.setBlock(floor, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 2);
|
||||
@@ -241,6 +267,11 @@ public final class AgeDimensionService {
|
||||
}
|
||||
|
||||
BlockPos spawn = floorCenter.above();
|
||||
if (!generatePlatform) {
|
||||
var safe = LinkingService.resolveDestination(level, spawn, true);
|
||||
if (safe.isEmpty()) return null;
|
||||
spawn = BlockPos.containing(safe.get());
|
||||
}
|
||||
level.setDefaultSpawnPos(spawn, 0.0F);
|
||||
return spawn;
|
||||
}
|
||||
@@ -325,7 +356,7 @@ public final class AgeDimensionService {
|
||||
server.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
|
||||
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
|
||||
);
|
||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
||||
return age.generationVersion() < AgeDefinition.LEGACY_GENERATOR_CUTOFF
|
||||
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
|
||||
}
|
||||
if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
|
||||
@@ -335,7 +366,7 @@ public final class AgeDimensionService {
|
||||
List.of()
|
||||
);
|
||||
settings.updateLayers();
|
||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
||||
return age.generationVersion() < AgeDefinition.LEGACY_GENERATOR_CUTOFF
|
||||
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
|
||||
}
|
||||
|
||||
@@ -345,7 +376,7 @@ public final class AgeDimensionService {
|
||||
NoiseGeneratorSettings baseSettings = noiseSettings.getOrThrow(settingsKey).value();
|
||||
Holder<NoiseGeneratorSettings> settings = Holder.direct(withBiomeMaterialRules(baseSettings));
|
||||
boolean caveTerrain = AgeBiomeSelection.isCave(profile);
|
||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
||||
return age.generationVersion() < AgeDefinition.LEGACY_GENERATOR_CUTOFF
|
||||
? new NoiseBasedChunkGenerator(biomeSource, settings)
|
||||
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed(),
|
||||
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of(),
|
||||
@@ -428,13 +459,13 @@ public final class AgeDimensionService {
|
||||
AgeDefinition age,
|
||||
AgeRuntimeProfile profile
|
||||
) {
|
||||
if (age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION) {
|
||||
if (age.generationVersion() < AgeDefinition.LEGACY_GENERATOR_CUTOFF) {
|
||||
return legacyBiomeSource(server, age, profile);
|
||||
}
|
||||
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);
|
||||
@@ -531,7 +562,9 @@ public final class AgeDimensionService {
|
||||
DimensionType base = server.registryAccess().lookupOrThrow(Registries.DIMENSION_TYPE)
|
||||
.getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, profile.dimensionType())).value();
|
||||
AgeTraits.Lighting lighting = age.traits().lighting();
|
||||
boolean hasSkyLight = base.hasSkyLight() && lighting != AgeTraits.Lighting.DARK;
|
||||
AgeSkyProfile sky = AgeSkyProfile.forAge(age);
|
||||
boolean hasSkyLight = (base.hasSkyLight() || !sky.bodies().isEmpty())
|
||||
&& lighting != AgeTraits.Lighting.DARK && !age.caveTerrain();
|
||||
float ambientLight = lighting == AgeTraits.Lighting.BRIGHT ? 0.25F : 0.0F;
|
||||
return new DimensionType(
|
||||
base.fixedTime(),
|
||||
@@ -546,7 +579,9 @@ public final class AgeDimensionService {
|
||||
base.height(),
|
||||
base.logicalHeight(),
|
||||
base.infiniburn(),
|
||||
AgeEnvironmentCompiler.skyEffects(age.plan(), base.effectsLocation()),
|
||||
age.plan() == null && sky.custom()
|
||||
? AgeEnvironmentCompiler.CUSTOM_SKY_EFFECTS
|
||||
: AgeEnvironmentCompiler.skyEffects(age.plan(), base.effectsLocation()),
|
||||
ambientLight,
|
||||
base.monsterSettings()
|
||||
);
|
||||
|
||||
@@ -2,21 +2,38 @@ package net.dnorberg.rivencraft.book;
|
||||
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
|
||||
|
||||
/** Resolves written environment pages without changing the seeded page-free Age path. */
|
||||
public final class AgeEnvironmentCompiler {
|
||||
public static final ResourceLocation CUSTOM_SKY_EFFECTS = ResourceLocation.fromNamespaceAndPath(
|
||||
"rivencraft", "custom_sky");
|
||||
private AgeEnvironmentCompiler() {}
|
||||
|
||||
public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) {
|
||||
if (plan == null) return seeded;
|
||||
AgeTraits applied;
|
||||
if (plan.version() >= 4) {
|
||||
AgeTraits base = applyLegacy(plan, seeded);
|
||||
CelestialWriting ordered = orderedCelestial(plan, seeded);
|
||||
return new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(),
|
||||
applied = new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(),
|
||||
ordered.period(), base.celestialAngle(), ordered.phase());
|
||||
} else {
|
||||
applied = applyLegacy(plan, seeded);
|
||||
}
|
||||
return applyLegacy(plan, seeded);
|
||||
AgeSkyProfile.compile(plan, applied);
|
||||
return forTerrain(applied, plan.runtimeProfile());
|
||||
}
|
||||
|
||||
/** Cave terrain has no exposed weather or meaningful sky clock, even with authored pages. */
|
||||
public static AgeTraits forTerrain(AgeTraits traits, AgeRuntimeProfile profile) {
|
||||
if (!AgeBiomeSelection.isCave(profile)) return traits;
|
||||
return new AgeTraits(traits.biomeDistribution(), traits.lighting(), AgeTraits.Weather.OFF,
|
||||
1.0D, 0.0F, 0.0F);
|
||||
}
|
||||
|
||||
/** Version-3 plans keep their frozen, stage-sorted environment interpretation. */
|
||||
@@ -57,8 +74,7 @@ public final class AgeEnvironmentCompiler {
|
||||
phaseDegrees = writtenPhase ? circularMidpoint(phaseDegrees, next) : next;
|
||||
writtenPhase = true;
|
||||
}
|
||||
case ANGLE_MODIFIER -> throw new IllegalArgumentException(
|
||||
"Sun angle pages require a custom sky renderer and are not supported yet");
|
||||
case ANGLE_MODIFIER -> { } // Consumed by the next independently rendered celestial.
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
@@ -71,6 +87,8 @@ public final class AgeEnvironmentCompiler {
|
||||
/** Vanilla effects are the sky styles the current renderer can honor exactly. */
|
||||
public static ResourceLocation skyEffects(AgeGenerationPlan plan, ResourceLocation fallback) {
|
||||
if (plan == null) return fallback;
|
||||
if (skyColor(plan) != -1 || staticSky(plan) || AgeSkyProfile.hasCustomCelestials(plan))
|
||||
return CUSTOM_SKY_EFFECTS;
|
||||
return plan.activeSymbols().stream()
|
||||
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SKY)
|
||||
.map(symbol -> ((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id())
|
||||
@@ -82,6 +100,48 @@ public final class AgeEnvironmentCompiler {
|
||||
}).findFirst().orElse(fallback);
|
||||
}
|
||||
|
||||
public static boolean staticSky(AgeGenerationPlan plan) {
|
||||
return plan != null && plan.activeSymbols().stream().anyMatch(symbol ->
|
||||
symbol.semantics().role() == SymbolSemantics.Role.SKY
|
||||
&& ((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id().equals("static"));
|
||||
}
|
||||
|
||||
/** An authored color is fixed; -1 means the biome or selected vanilla sky supplies it. */
|
||||
public static int skyColor(AgeGenerationPlan plan) {
|
||||
return AgeSkyProfile.authoredSkyColor(plan);
|
||||
}
|
||||
|
||||
public static int selectedSkyColor(AgeGenerationPlan plan, ResourceLocation fallback, int biomeSkyColor) {
|
||||
int authored = skyColor(plan);
|
||||
if (authored != -1) return authored;
|
||||
if (staticSky(plan)) return 0xFF929292;
|
||||
ResourceLocation effects = skyEffects(plan, fallback);
|
||||
return previewSkyColor(effects, biomeSkyColor);
|
||||
}
|
||||
|
||||
/** Shared server answer for book previews and the fallback crystal portal surface. */
|
||||
public static int previewSkyColor(ServerLevel level, BlockPos origin) {
|
||||
int biomeColor = level.getBiome(origin).value().getSkyColor();
|
||||
var age = AgeRegistrySavedData.get(level).definitions().stream()
|
||||
.filter(candidate -> AgeDimensionService.dimensionKey(candidate).equals(level.dimension()))
|
||||
.findFirst();
|
||||
if (age.isEmpty()) return previewSkyColor(level.dimensionType().effectsLocation(), biomeColor);
|
||||
var definition = age.orElseThrow();
|
||||
AgeSkyProfile sky = AgeSkyProfile.forAge(definition);
|
||||
if (definition.plan() == null && definition.generationVersion() >= AgeSkyProfile.RANDOM_SKY_GENERATION_VERSION) {
|
||||
if (sky.color() != -1) return sky.color();
|
||||
if (sky.style() == AgeSkyProfile.Style.STATIC) return 0xFF929292;
|
||||
}
|
||||
var profile = definition.plan() == null
|
||||
? AgeRuntimeProfiles.resolve(definition.generationProfile())
|
||||
: definition.plan().runtimeProfile();
|
||||
ResourceLocation baseEffects = level.getServer().registryAccess()
|
||||
.lookupOrThrow(Registries.DIMENSION_TYPE)
|
||||
.getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, profile.dimensionType()))
|
||||
.value().effectsLocation();
|
||||
return selectedSkyColor(definition.plan(), baseEffects, biomeColor);
|
||||
}
|
||||
|
||||
public static boolean hasWrittenPhase(AgeGenerationPlan plan) {
|
||||
if (plan == null) return false;
|
||||
if (plan.version() >= 4) return orderedCelestial(plan, null).writtenPhase();
|
||||
@@ -95,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 ->
|
||||
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;
|
||||
@@ -15,6 +16,7 @@ import net.minecraft.resources.ResourceLocation;
|
||||
public record AgeInstability(int version, int score, List<Contribution> breakdown) {
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
private static final int BRIGHT_LIGHTING_COST = 500;
|
||||
private static final int INEFFECTIVE_CAVE_PAGE_COST = 100;
|
||||
private static final int EXCESS_LEGACY_STRUCTURE_COST = 100;
|
||||
private static final int FORCED_STRUCTURE_BIOME_COST = 100;
|
||||
private static final Map<ResourceLocation, String> COUNTED_STRUCTURES = Map.ofEntries(
|
||||
@@ -50,6 +52,15 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
}
|
||||
|
||||
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries) {
|
||||
return compute(plan, traits, registries, Long.MIN_VALUE);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -81,6 +92,17 @@ 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, policy)
|
||||
: plan == null ? new AgeSkyProfile(AgeSkyProfile.Style.INHERITED, -1, List.of())
|
||||
: AgeSkyProfile.compile(plan, traits);
|
||||
int seenSuns = 0;
|
||||
for (AgeSkyProfile.Body body : sky.bodies()) {
|
||||
if (body.kind() != AgeSkyProfile.Kind.SUN) continue;
|
||||
if (++seenSuns <= 2) continue;
|
||||
int points = 1000 * (seenSuns - 2);
|
||||
contributions.add(new Contribution("extra_sun", -1, points,
|
||||
"Sun " + seenSuns + " adds growing instability"));
|
||||
}
|
||||
return new AgeInstability(CURRENT_VERSION,
|
||||
contributions.stream().mapToInt(Contribution::points).sum(), contributions);
|
||||
}
|
||||
@@ -88,7 +110,20 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
private static void addAuditedSymbolCosts(AgeGenerationPlan plan, RegistryAccess registries,
|
||||
List<Contribution> contributions) {
|
||||
Map<ResourceLocation, Integer> structureCounts = new HashMap<>();
|
||||
boolean cave = AgeBiomeSelection.isCave(plan.runtimeProfile());
|
||||
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||
if (cave) {
|
||||
var role = symbol.semantics().role();
|
||||
if (switch (role) {
|
||||
case WEATHER, SEASON, CELESTIAL, PERIOD_MODIFIER, PHASE_MODIFIER,
|
||||
ANGLE_MODIFIER, DIRECTION_MODIFIER -> true;
|
||||
default -> false;
|
||||
}) {
|
||||
contributions.add(new Contribution("cave_ignored_" + role.id(), symbol.index(),
|
||||
INEFFECTIVE_CAVE_PAGE_COST,
|
||||
"Sky or weather page has no effect in Cave terrain"));
|
||||
}
|
||||
}
|
||||
if (symbol.semantics().role()
|
||||
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.ENVIRONMENT
|
||||
&& symbol.semantics().value()
|
||||
@@ -121,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"));
|
||||
@@ -139,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,49 @@ public final class AgePersistenceGameTests {
|
||||
private AgePersistenceGameTests() {
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void modifierSeedChangePersistsWithoutChangingAgeIdentity(GameTestHelper helper) {
|
||||
UUID id = UUID.randomUUID();
|
||||
AgeDefinition original = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(id, "Reseed " + id));
|
||||
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
|
||||
long newSeed = original.seed() == 987654321L ? 987654322L : 987654321L;
|
||||
helper.assertTrue(registry.reseed(helper.getLevel().getServer(), id, newSeed),
|
||||
"An unoccupied Age must accept a new seed");
|
||||
AgeDefinition changed = registry.find(id).orElseThrow();
|
||||
helper.assertValueEqual(changed.id(), original.id(), "Reseeding must preserve the Age identity");
|
||||
helper.assertValueEqual(changed.dimensionId(), original.dimensionId(),
|
||||
"Reseeding must preserve its dimension and existing chunk location");
|
||||
helper.assertValueEqual(changed.seed(), newSeed, "The new generator seed must be saved");
|
||||
AgeDefinition restored = AgeRegistrySavedData.load(
|
||||
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||
helper.getLevel().registryAccess()).find(id).orElseThrow();
|
||||
helper.assertValueEqual(restored.seed(), newSeed, "The new seed must survive reload");
|
||||
var ageLevel = AgeDimensionService.ensureLoaded(helper.getLevel().getServer(), changed);
|
||||
helper.assertTrue(ageLevel != null, "The reseeded Age must reload");
|
||||
helper.assertValueEqual(ageLevel.getSeed(), newSeed,
|
||||
"The runtime level must use the saved seed for new chunks");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void retiredAgeIdentitySurvivesSaveAndCannotLoad(GameTestHelper helper) {
|
||||
UUID id = UUID.randomUUID();
|
||||
AgeReference reference = new AgeReference(id, "Retired " + id);
|
||||
AgeDefinition age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(), reference);
|
||||
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
|
||||
helper.assertTrue(registry.markDead(id), "The first retirement must set the tombstone");
|
||||
helper.assertTrue(!registry.markDead(id), "Retirement must be idempotent");
|
||||
helper.assertTrue(AgeDimensionService.ensureLoaded(helper.getLevel().getServer(), age) == null,
|
||||
"A retired Age must never load from an old book");
|
||||
AgeRegistrySavedData restored = AgeRegistrySavedData.load(
|
||||
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||
helper.getLevel().registryAccess());
|
||||
helper.assertTrue(restored.isDead(id) && restored.find(id).isPresent(),
|
||||
"Both the retired flag and reserved Age identity must survive reload");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void authoredPlanRetainsResolvedMeaningAndAgeIdentity(GameTestHelper helper) {
|
||||
OrderedWriting writing = new OrderedWriting(List.of(
|
||||
@@ -82,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),
|
||||
@@ -141,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),
|
||||
@@ -164,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(),
|
||||
@@ -173,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,
|
||||
@@ -377,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,
|
||||
|
||||
@@ -5,6 +5,9 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.List;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import net.minecraft.nbt.StringTag;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
@@ -13,6 +16,7 @@ import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.util.datafix.DataFixTypes;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.neoforged.fml.ModList;
|
||||
@@ -21,6 +25,7 @@ import net.neoforged.fml.ModList;
|
||||
public final class AgeRegistrySavedData extends SavedData {
|
||||
private static final String DATA_NAME = "rivencraft_ages";
|
||||
private static final String AGES_TAG = "Ages";
|
||||
private static final String DEAD_AGES_TAG = "DeadAges";
|
||||
private static final String ID_TAG = "Id";
|
||||
private static final String NAME_TAG = "Name";
|
||||
private static final String SEED_TAG = "Seed";
|
||||
@@ -34,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";
|
||||
@@ -50,6 +56,7 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
);
|
||||
|
||||
private final Map<UUID, AgeDefinition> ages = new HashMap<>();
|
||||
private final Set<UUID> deadAges = new HashSet<>();
|
||||
|
||||
public static AgeRegistrySavedData get(ServerLevel level) {
|
||||
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
|
||||
@@ -71,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) {
|
||||
@@ -110,6 +127,16 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
return List.copyOf(ages.values());
|
||||
}
|
||||
|
||||
public boolean isDead(UUID ageId) { return deadAges.contains(ageId); }
|
||||
|
||||
/** Keep the ID and definition reserved so an old book cannot regenerate a retired Age. */
|
||||
public boolean markDead(UUID ageId) {
|
||||
if (!ages.containsKey(ageId)) return false;
|
||||
boolean changed = deadAges.add(ageId);
|
||||
if (changed) setDirty();
|
||||
return changed;
|
||||
}
|
||||
|
||||
public AgeDefinition setSpawn(UUID ageId, BlockPos spawn) {
|
||||
AgeDefinition existing = ages.get(ageId);
|
||||
if (existing == null) {
|
||||
@@ -121,6 +148,33 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Changes the generator for future chunks; existing chunk data is deliberately preserved. */
|
||||
public boolean reseed(MinecraftServer server, UUID ageId, long seed) {
|
||||
AgeDefinition previous = ages.get(ageId);
|
||||
if (previous == null || isDead(ageId)) return false;
|
||||
if (previous.seed() == seed) return true;
|
||||
ServerLevel loaded = server.getLevel(AgeDimensionService.dimensionKey(previous));
|
||||
if (loaded != null && !loaded.players().isEmpty()) return false;
|
||||
|
||||
// Legacy changes the Age's seed, not its already-written pages or selected traits.
|
||||
// Keeping the frozen plan and feature list prevents a visited Age from changing
|
||||
// its identity while newly generated chunks use the revised terrain noise.
|
||||
AgeGenerationPlan oldPlan = previous.plan();
|
||||
AgeGenerationPlan plan = oldPlan == null ? null : new AgeGenerationPlan(oldPlan.version(), seed,
|
||||
oldPlan.writing(), oldPlan.symbols(), oldPlan.runtimeProfile(), oldPlan.biomeSelection(),
|
||||
oldPlan.activeSymbolIndices(), oldPlan.writingInstability());
|
||||
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.randomPagePolicy());
|
||||
if (AgeDimensionService.unload(server, previous) == AgeDimensionService.UnloadResult.FAILED)
|
||||
return false;
|
||||
ages.put(ageId, changed);
|
||||
setDirty();
|
||||
return true;
|
||||
}
|
||||
|
||||
private AgeDefinition getOrCreate(AgeReference reference, OrderedWriting writing,
|
||||
long worldSeed, long createdGameTime, RegistryAccess registries) {
|
||||
AgeDefinition existing = ages.get(reference.id());
|
||||
@@ -131,11 +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);
|
||||
AgeTraits effectiveTraits = AgeEnvironmentCompiler.apply(plan, traits);
|
||||
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, 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,
|
||||
plan == null ? policy : RandomPagePolicy.UNRESTRICTED);
|
||||
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
|
||||
AgeDefinition created = new AgeDefinition(
|
||||
reference.id(),
|
||||
@@ -148,11 +210,14 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
null,
|
||||
effectiveTraits,
|
||||
plan,
|
||||
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")),
|
||||
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"),
|
||||
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();
|
||||
@@ -161,6 +226,11 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
|
||||
static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
AgeRegistrySavedData data = new AgeRegistrySavedData();
|
||||
ListTag deadTags = tag.getList(DEAD_AGES_TAG, StringTag.TAG_STRING);
|
||||
for (int index = 0; index < deadTags.size(); index++) {
|
||||
try { data.deadAges.add(UUID.fromString(deadTags.getString(index))); }
|
||||
catch (IllegalArgumentException ignored) { }
|
||||
}
|
||||
ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND);
|
||||
for (int index = 0; index < serializedAges.size(); index++) {
|
||||
CompoundTag entry = serializedAges.getCompound(index);
|
||||
@@ -214,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;
|
||||
@@ -259,11 +332,15 @@ 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);
|
||||
});
|
||||
tag.put(AGES_TAG, serializedAges);
|
||||
ListTag deadTags = new ListTag();
|
||||
deadAges.stream().sorted().forEach(id -> deadTags.add(StringTag.valueOf(id.toString())));
|
||||
tag.put(DEAD_AGES_TAG, deadTags);
|
||||
return tag;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -19,18 +19,33 @@ public enum AgeSeasonMode {
|
||||
|
||||
/** An omitted page is selected once when an Age is created with Serene installed. */
|
||||
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled) {
|
||||
return select(plan, ageSeed, sereneInstalled, plan == null ? null : plan.runtimeProfile());
|
||||
}
|
||||
|
||||
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. */
|
||||
public static AgeSeasonMode from(AgeDefinition age) {
|
||||
if (age.caveTerrain()) return NO_SEASONS;
|
||||
return age.seasonMode() == null ? from(age.plan()) : age.seasonMode();
|
||||
}
|
||||
|
||||
@@ -49,6 +64,7 @@ public enum AgeSeasonMode {
|
||||
|
||||
public static AgeSeasonMode from(AgeGenerationPlan plan) {
|
||||
if (plan == null) return SEASONS;
|
||||
if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return NO_SEASONS;
|
||||
return plan.activeSymbols().stream()
|
||||
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON)
|
||||
.map(symbol -> valueOf(((SymbolSemantics.ChoiceValue) symbol.semantics().value())
|
||||
@@ -58,6 +74,7 @@ public enum AgeSeasonMode {
|
||||
|
||||
public static void validate(AgeGenerationPlan plan) {
|
||||
if (plan == null) return;
|
||||
if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return;
|
||||
if (hasAuthoredPage(plan) && from(plan) != NO_SEASONS
|
||||
&& !ModList.get().isLoaded("sereneseasons")) {
|
||||
throw new IllegalArgumentException("This seasonal page requires Serene Seasons to create the Age");
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** The ordered, deterministic sky specification for an Age. No client data is trusted here. */
|
||||
public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
|
||||
public static final int RANDOM_SKY_GENERATION_VERSION = 17;
|
||||
private static final int[] COLORS = {0xB74A50, 0xD9834B, 0xDBC574, 0x5AAB80,
|
||||
0x6FA8DF, 0x9870B9, 0xD5DBE1, 0x252531};
|
||||
|
||||
public AgeSkyProfile {
|
||||
bodies = List.copyOf(bodies);
|
||||
long suns = bodies.stream().filter(body -> body.kind() == Kind.SUN).count();
|
||||
long moons = bodies.size() - suns;
|
||||
if (suns > 10 || moons > 10) throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
|
||||
}
|
||||
|
||||
public static AgeSkyProfile forAge(AgeDefinition age) {
|
||||
if (age.plan() != null) return compile(age.plan(), age.traits());
|
||||
if (age.generationVersion() >= RANDOM_SKY_GENERATION_VERSION)
|
||||
return random(age.seed(), age.traits(), age.randomPagePolicy());
|
||||
return new AgeSkyProfile(Style.INHERITED, -1, List.of());
|
||||
}
|
||||
|
||||
public static AgeSkyProfile random(long seed, AgeTraits traits) {
|
||||
return random(seed, traits, RandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static AgeSkyProfile random(long seed, AgeTraits traits, RandomPagePolicy policy) {
|
||||
Random random = new Random(seed ^ 0x534B5950524F464CL);
|
||||
List<Style> styles = policy.required(java.util.Arrays.stream(Style.values())
|
||||
.filter(style -> style != Style.INHERITED || !policy.restricts("sky")).toList(),
|
||||
AgeSkyProfile::skyPage, Style.INHERITED);
|
||||
Style style = styles.get(random.nextInt(styles.size()));
|
||||
int color = randomColor(random);
|
||||
int suns = policy.allows(ResourceLocation.fromNamespaceAndPath("rivencraft", "celestial/sun"))
|
||||
? random.nextInt(4) : 0;
|
||||
int moons = policy.allows(ResourceLocation.fromNamespaceAndPath("rivencraft", "celestial/moon"))
|
||||
? random.nextInt(4) : 0;
|
||||
List<Body> bodies = new ArrayList<>();
|
||||
for (int i = 0; i < suns; i++)
|
||||
bodies.add(new Body(Kind.SUN, randomColor(random),
|
||||
0.75F + random.nextFloat(), random.nextFloat() * 360.0F,
|
||||
traits.celestialPeriod(), random.nextFloat(), random.nextBoolean()));
|
||||
for (int i = 0; i < moons; i++)
|
||||
bodies.add(new Body(Kind.MOON, randomColor(random),
|
||||
0.75F + random.nextFloat(), random.nextFloat() * 360.0F,
|
||||
traits.celestialPeriod(), random.nextFloat(), random.nextBoolean()));
|
||||
return new AgeSkyProfile(style, color, bodies);
|
||||
}
|
||||
|
||||
private static ResourceLocation skyPage(Style style) {
|
||||
return ResourceLocation.fromNamespaceAndPath("rivencraft",
|
||||
"sky/" + (style == Style.INHERITED ? "inherited" : style.name().toLowerCase(java.util.Locale.ROOT)));
|
||||
}
|
||||
|
||||
public static AgeSkyProfile compile(AgeGenerationPlan plan, AgeTraits traits) {
|
||||
if (plan == null) return new AgeSkyProfile(Style.INHERITED, -1, List.of());
|
||||
Style style = Style.INHERITED;
|
||||
int color = authoredSkyColor(plan);
|
||||
boolean customBodies = hasCustomCelestials(plan);
|
||||
List<Body> bodies = new ArrayList<>();
|
||||
Set<Integer> active = new HashSet<>(plan.activeSymbolIndices());
|
||||
AgeTraits seeded = AgeTraits.select(plan.ageSeed());
|
||||
double period = 1.0D;
|
||||
float angle = seeded.celestialAngle();
|
||||
float phase = seeded.celestialPhase();
|
||||
float size = 1.0F;
|
||||
List<Integer> bodyColors = new ArrayList<>();
|
||||
ColorAccumulator pendingColors = new ColorAccumulator();
|
||||
Double pendingNumber = null;
|
||||
boolean clockwise = true;
|
||||
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||
if (!active.contains(symbol.index())) continue;
|
||||
SymbolSemantics semantics = symbol.semantics();
|
||||
Double number = pendingNumber;
|
||||
if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null;
|
||||
switch (semantics.role()) {
|
||||
case SKY -> {
|
||||
pendingColors.clear();
|
||||
style = switch (((SymbolSemantics.ChoiceValue) semantics.value()).id()) {
|
||||
case "overworld" -> Style.OVERWORLD;
|
||||
case "nether" -> Style.NETHER;
|
||||
case "end" -> Style.END;
|
||||
case "static" -> Style.STATIC;
|
||||
default -> throw new IllegalArgumentException("Unknown sky style");
|
||||
};
|
||||
}
|
||||
case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue)
|
||||
semantics.value()).amount();
|
||||
case COLOR -> {
|
||||
pendingColors.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(),
|
||||
number == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : number.intValue());
|
||||
}
|
||||
case PERIOD_MODIFIER -> period = CelestialPageValues.value(semantics, number);
|
||||
case ANGLE_MODIFIER -> angle = (float) CelestialPageValues.value(semantics, number);
|
||||
case PHASE_MODIFIER -> phase = (float) (CelestialPageValues.value(semantics, number) / 360.0D);
|
||||
case SIZE_MODIFIER -> size = (float) CelestialPageValues.value(semantics, number);
|
||||
case CELESTIAL_COLOR -> bodyColors.add(color(
|
||||
((SymbolSemantics.ChoiceValue) semantics.value()).id()));
|
||||
case DIRECTION_MODIFIER -> clockwise = ((SymbolSemantics.ChoiceValue) semantics.value())
|
||||
.id().equals("clockwise");
|
||||
case CELESTIAL -> {
|
||||
String choice = ((SymbolSemantics.ChoiceValue) semantics.value()).id();
|
||||
int bodyColor = pendingColors.written() ? pendingColors.rgb()
|
||||
: bodyColors.isEmpty() ? 0xFFFFFF : mixColors(bodyColors);
|
||||
if (choice.equals("normal_time")) {
|
||||
if (customBodies) {
|
||||
bodies.add(new Body(Kind.SUN, bodyColor, size, angle, period, phase, clockwise));
|
||||
bodies.add(new Body(Kind.MOON, bodyColor, 1.0F, angle, period,
|
||||
(phase + 0.5F) % 1.0F, clockwise));
|
||||
}
|
||||
} else {
|
||||
bodies.add(new Body(choice.equals("sun") ? Kind.SUN : Kind.MOON,
|
||||
bodyColor, size, angle, period, phase, clockwise));
|
||||
}
|
||||
period = 1.0D;
|
||||
angle = seeded.celestialAngle();
|
||||
phase = seeded.celestialPhase();
|
||||
size = 1.0F;
|
||||
bodyColors.clear();
|
||||
pendingColors.clear();
|
||||
clockwise = true;
|
||||
}
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
// Missing branches are seed-completed, including the celestial branch.
|
||||
// An explicit sky or sky-color page does not suppress that completion.
|
||||
if (plan.activeSymbols().stream().noneMatch(symbol ->
|
||||
symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL)) {
|
||||
bodies.addAll(random(plan.ageSeed(), seeded).bodies());
|
||||
}
|
||||
return new AgeSkyProfile(style, color, bodies);
|
||||
}
|
||||
|
||||
public static int mixColors(List<Integer> colors) {
|
||||
if (colors.isEmpty()) return -1;
|
||||
long red = 0, green = 0, blue = 0;
|
||||
for (int color : colors) {
|
||||
red += color >> 16 & 255;
|
||||
green += color >> 8 & 255;
|
||||
blue += color & 255;
|
||||
}
|
||||
int count = colors.size();
|
||||
return (int) ((red + count / 2) / count) << 16
|
||||
| (int) ((green + count / 2) / count) << 8
|
||||
| (int) ((blue + count / 2) / count);
|
||||
}
|
||||
|
||||
/** Shared color pages affect the next Sky or celestial page; trailing colors tint the sky. */
|
||||
public static int authoredSkyColor(AgeGenerationPlan plan) {
|
||||
if (plan == null) return -1;
|
||||
ColorAccumulator pending = new ColorAccumulator();
|
||||
Integer multiplier = null;
|
||||
int skyColor = -1;
|
||||
List<Integer> legacyColors = new ArrayList<>();
|
||||
Set<Integer> active = new HashSet<>(plan.activeSymbolIndices());
|
||||
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||
if (!active.contains(symbol.index())) continue;
|
||||
SymbolSemantics semantics = symbol.semantics();
|
||||
if (semantics.role() != SymbolSemantics.Role.COLOR
|
||||
&& semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) multiplier = null;
|
||||
switch (semantics.role()) {
|
||||
case COLOR_NUMBER -> multiplier = (int) ((SymbolSemantics.NumberValue) semantics.value()).amount();
|
||||
case COLOR -> {
|
||||
pending.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(),
|
||||
multiplier == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : multiplier);
|
||||
multiplier = null;
|
||||
}
|
||||
case SKY -> {
|
||||
if (pending.written()) skyColor = pending.rgb();
|
||||
pending.clear();
|
||||
}
|
||||
case CELESTIAL -> pending.clear();
|
||||
case SKY_COLOR -> legacyColors.add(color(
|
||||
((SymbolSemantics.ChoiceValue) semantics.value()).id()));
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
if (pending.written()) skyColor = pending.rgb();
|
||||
return skyColor != -1 ? skyColor : mixColors(legacyColors);
|
||||
}
|
||||
|
||||
private static int randomColorNumber(long ageSeed, int pageIndex) {
|
||||
return new Random(ageSeed ^ (0x9E3779B97F4A7C15L * (pageIndex + 1L))).nextInt(25);
|
||||
}
|
||||
|
||||
private static final class ColorAccumulator {
|
||||
private int red;
|
||||
private int green;
|
||||
private int blue;
|
||||
private boolean written;
|
||||
|
||||
void add(String color, int multiplier) {
|
||||
int amount = multiplier * 10;
|
||||
written = true;
|
||||
switch (color) {
|
||||
case "red" -> red += amount;
|
||||
case "green" -> green += amount;
|
||||
case "blue" -> blue += amount;
|
||||
case "white" -> { red += amount; green += amount; blue += amount; }
|
||||
case "black" -> { red -= amount; green -= amount; blue -= amount; }
|
||||
default -> throw new IllegalArgumentException("Unknown shared color " + color);
|
||||
}
|
||||
}
|
||||
|
||||
boolean written() { return written; }
|
||||
|
||||
int rgb() {
|
||||
return clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
|
||||
}
|
||||
|
||||
void clear() { red = green = blue = 0; written = false; }
|
||||
|
||||
private static int clamp(int channel) { return Math.max(0, Math.min(255, channel)); }
|
||||
}
|
||||
|
||||
private static int randomColor(Random random) {
|
||||
int count = 1 + random.nextInt(3);
|
||||
List<Integer> colors = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) colors.add(COLORS[random.nextInt(COLORS.length)]);
|
||||
return mixColors(colors);
|
||||
}
|
||||
|
||||
public static boolean hasCustomCelestials(AgeGenerationPlan plan) {
|
||||
return plan != null && (hasColorForCelestial(plan) || plan.activeSymbols().stream().anyMatch(symbol ->
|
||||
symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL
|
||||
&& !((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id().equals("normal_time")
|
||||
|| symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL_COLOR
|
||||
|| symbol.semantics().role() == SymbolSemantics.Role.SIZE_MODIFIER
|
||||
|| symbol.semantics().role() == SymbolSemantics.Role.ANGLE_MODIFIER
|
||||
|| symbol.semantics().role() == SymbolSemantics.Role.DIRECTION_MODIFIER));
|
||||
}
|
||||
|
||||
private static boolean hasColorForCelestial(AgeGenerationPlan plan) {
|
||||
Set<Integer> active = new HashSet<>(plan.activeSymbolIndices());
|
||||
boolean pending = false;
|
||||
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||
if (!active.contains(symbol.index())) continue;
|
||||
switch (symbol.semantics().role()) {
|
||||
case COLOR -> pending = true;
|
||||
case SKY -> pending = false;
|
||||
case CELESTIAL -> {
|
||||
if (pending) return true;
|
||||
pending = false;
|
||||
}
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean custom() {
|
||||
return style == Style.STATIC || color != -1 || !bodies.isEmpty();
|
||||
}
|
||||
|
||||
/** Vanilla has one skylight channel; combine visible bodies into that channel. */
|
||||
public float lightAt(double dayTime) {
|
||||
float sunlight = 0.0F;
|
||||
float moonlight = 0.0F;
|
||||
for (Body body : bodies) {
|
||||
double orbit = body.period() == 0.0D ? body.phase()
|
||||
: (body.clockwise() ? 1.0D : -1.0D) * dayTime
|
||||
/ (24000.0D * body.period()) + body.phase();
|
||||
float altitude = (float) Math.cos(orbit * Math.PI * 2.0D);
|
||||
if (altitude <= 0.0F) continue;
|
||||
if (body.kind() == Kind.SUN) sunlight += altitude * (1.0F - 0.85F * eclipseFor(body, dayTime));
|
||||
else moonlight += altitude * 0.18F;
|
||||
}
|
||||
return Math.min(1.0F, sunlight > 0.0F ? sunlight : moonlight);
|
||||
}
|
||||
|
||||
/** Disc overlap in the rendered sky, from first contact (0) to centered alignment (1). */
|
||||
public float eclipseFor(Body body, double dayTime) {
|
||||
float strongest = 0.0F;
|
||||
net.minecraft.world.phys.Vec3 bodyDirection = direction(body, dayTime);
|
||||
for (Body other : bodies) {
|
||||
if (other.kind() == body.kind()) continue;
|
||||
double dot = Math.max(-1.0D, Math.min(1.0D,
|
||||
bodyDirection.dot(direction(other, dayTime))));
|
||||
double separation = Math.acos(dot);
|
||||
// The outer 32x32 quad is only glow. The opaque body occupies its
|
||||
// central 8x8 pixels, so contact must use one quarter of its size.
|
||||
double radius = Math.atan((body.kind() == Kind.SUN ? 30.0D : 20.0D)
|
||||
* body.size() * 0.25D / 100.0D)
|
||||
+ Math.atan((other.kind() == Kind.SUN ? 30.0D : 20.0D)
|
||||
* other.size() * 0.25D / 100.0D);
|
||||
if (radius > 0.0D) strongest = Math.max(strongest,
|
||||
(float) Math.max(0.0D, 1.0D - separation / radius));
|
||||
}
|
||||
return strongest;
|
||||
}
|
||||
|
||||
public float eclipseAt(double dayTime) {
|
||||
float strongest = 0.0F;
|
||||
for (Body body : bodies) {
|
||||
if (body.kind() == Kind.SUN && direction(body, dayTime).y > 0.0D)
|
||||
strongest = Math.max(strongest, eclipseFor(body, dayTime));
|
||||
}
|
||||
return strongest;
|
||||
}
|
||||
|
||||
private static net.minecraft.world.phys.Vec3 direction(Body body, double dayTime) {
|
||||
double orbit = body.period() == 0.0D ? body.phase()
|
||||
: (body.clockwise() ? 1.0D : -1.0D) * dayTime
|
||||
/ (24000.0D * body.period()) + body.phase();
|
||||
double tilt = orbit * Math.PI * 2.0D;
|
||||
double bearing = Math.toRadians(body.angle());
|
||||
double lateral = Math.sin(tilt);
|
||||
return new net.minecraft.world.phys.Vec3(
|
||||
Math.sin(bearing) * lateral, Math.cos(tilt), Math.cos(bearing) * lateral);
|
||||
}
|
||||
|
||||
public static int color(String name) {
|
||||
return switch (name) {
|
||||
case "red" -> COLORS[0];
|
||||
case "orange" -> COLORS[1];
|
||||
case "yellow" -> COLORS[2];
|
||||
case "green" -> COLORS[3];
|
||||
case "blue" -> COLORS[4];
|
||||
case "purple" -> COLORS[5];
|
||||
case "white" -> COLORS[6];
|
||||
case "black" -> COLORS[7];
|
||||
default -> throw new IllegalArgumentException("Unknown sky color " + name);
|
||||
};
|
||||
}
|
||||
|
||||
public enum Style { INHERITED, OVERWORLD, NETHER, END, STATIC }
|
||||
public enum Kind { SUN, MOON }
|
||||
public record Body(Kind kind, int color, float size, float angle,
|
||||
double period, float phase, boolean clockwise) { }
|
||||
}
|
||||
@@ -178,8 +178,11 @@ public final class AgeStructureGameTests {
|
||||
var age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(java.util.UUID.randomUUID(), "Mixed " + firstBiome), writing);
|
||||
int expected = firstBiome.equals("the_end") ? 200 : 0;
|
||||
helper.assertValueEqual(age.instability().score(), expected,
|
||||
"Each Village page must charge 100 if any selected biome needs the override");
|
||||
helper.assertValueEqual(age.instability().breakdown().stream()
|
||||
.filter(part -> part.reason().equals("forced_biome_villages"))
|
||||
.mapToInt(AgeInstability.Contribution::points).sum(), expected,
|
||||
"Each Village page must charge 100 if any selected biome needs the override; "
|
||||
+ "seed-completed celestial instability is independent");
|
||||
helper.assertValueEqual(age.instability().breakdown().stream()
|
||||
.filter(part -> part.reason().equals("forced_biome_villages")).count(),
|
||||
firstBiome.equals("the_end") ? 2L : 0L,
|
||||
|
||||
@@ -157,7 +157,14 @@ public final class AgeStructureSelection {
|
||||
|
||||
public static List<AgeFeatureSelection.Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile,
|
||||
AgeBiomeSelection biomes, RegistryAccess registries) {
|
||||
return randomChoices(ageSeed, profile, biomes, registries, RandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static List<AgeFeatureSelection.Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile,
|
||||
AgeBiomeSelection biomes, RegistryAccess registries,
|
||||
RandomPagePolicy policy) {
|
||||
List<LegacyRankChoice.Option<ResourceLocation>> eligible = new ArrayList<>(FEATURES.stream()
|
||||
.filter(id -> policy.allows(ResourceLocation.fromNamespaceAndPath("rivencraft", "feature/" + id.getPath())))
|
||||
.filter(id -> error(id, profile, biomes, registries) == null)
|
||||
.filter(id -> dungeons(id) || biomeCompatible(id, biomes, registries))
|
||||
.map(id -> new LegacyRankChoice.Option<>(id, randomRank(id))).toList());
|
||||
|
||||
@@ -2,6 +2,8 @@ package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.Random;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** Immutable random-Age traits selected once from the Age seed. */
|
||||
public record AgeTraits(
|
||||
@@ -29,8 +31,8 @@ public record AgeTraits(
|
||||
if (biomeDistribution == null || lighting == null || weather == null) {
|
||||
throw new IllegalArgumentException("Age trait choices cannot be null");
|
||||
}
|
||||
if (celestialPeriod != STOPPED_CELESTIAL_PERIOD
|
||||
&& (celestialPeriod < MIN_CELESTIAL_PERIOD || celestialPeriod > MAX_CELESTIAL_PERIOD)) {
|
||||
if (!Double.isFinite(celestialPeriod)
|
||||
|| celestialPeriod < STOPPED_CELESTIAL_PERIOD || celestialPeriod > MAX_CELESTIAL_PERIOD) {
|
||||
throw new IllegalArgumentException("Celestial period is outside the legacy range");
|
||||
}
|
||||
if (celestialAngle < MIN_CELESTIAL_ANGLE || celestialAngle >= MAX_CELESTIAL_ANGLE) {
|
||||
@@ -42,8 +44,12 @@ public record AgeTraits(
|
||||
}
|
||||
|
||||
public static AgeTraits select(long seed) {
|
||||
return select(seed, RandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static AgeTraits select(long seed, RandomPagePolicy policy) {
|
||||
Random random = new Random(seed ^ 0x524956454E435241L);
|
||||
BiomeDistribution biomeDistribution = LegacyRankChoice.choose(random, List.of(
|
||||
BiomeDistribution biomeDistribution = LegacyRankChoice.choose(random, policy.required(List.of(
|
||||
new LegacyRankChoice.Option<>(BiomeDistribution.NATIVE, 1),
|
||||
new LegacyRankChoice.Option<>(BiomeDistribution.SINGLE, 1),
|
||||
new LegacyRankChoice.Option<>(BiomeDistribution.MEDIUM, 1),
|
||||
@@ -52,12 +58,16 @@ public record AgeTraits(
|
||||
new LegacyRankChoice.Option<>(BiomeDistribution.TINY, 2),
|
||||
new LegacyRankChoice.Option<>(BiomeDistribution.SMALL, 2),
|
||||
new LegacyRankChoice.Option<>(BiomeDistribution.LARGE, 2),
|
||||
new LegacyRankChoice.Option<>(BiomeDistribution.HUGE, 2)));
|
||||
Lighting lighting = LegacyRankChoice.choose(random, List.of(
|
||||
new LegacyRankChoice.Option<>(BiomeDistribution.HUGE, 2)),
|
||||
option -> page("biome", option.value()),
|
||||
new LegacyRankChoice.Option<>(BiomeDistribution.NATIVE, 1)));
|
||||
Lighting lighting = LegacyRankChoice.choose(random, policy.required(List.of(
|
||||
new LegacyRankChoice.Option<>(Lighting.NORMAL, 1),
|
||||
new LegacyRankChoice.Option<>(Lighting.DARK, 2),
|
||||
new LegacyRankChoice.Option<>(Lighting.BRIGHT, 2)));
|
||||
Weather weather = LegacyRankChoice.choose(random, List.of(
|
||||
new LegacyRankChoice.Option<>(Lighting.BRIGHT, 2)),
|
||||
option -> page("lighting", option.value()),
|
||||
new LegacyRankChoice.Option<>(Lighting.NORMAL, 1)));
|
||||
Weather weather = LegacyRankChoice.choose(random, policy.required(List.of(
|
||||
new LegacyRankChoice.Option<>(Weather.NORMAL, 1),
|
||||
new LegacyRankChoice.Option<>(Weather.FAST, 2),
|
||||
new LegacyRankChoice.Option<>(Weather.SLOW, 2),
|
||||
@@ -66,7 +76,9 @@ public record AgeTraits(
|
||||
new LegacyRankChoice.Option<>(Weather.CLOUDY, 2),
|
||||
new LegacyRankChoice.Option<>(Weather.RAIN, 2),
|
||||
new LegacyRankChoice.Option<>(Weather.SNOW, 2),
|
||||
new LegacyRankChoice.Option<>(Weather.STORM, 2)));
|
||||
new LegacyRankChoice.Option<>(Weather.STORM, 2)),
|
||||
option -> page("weather", option.value()),
|
||||
new LegacyRankChoice.Option<>(Weather.NORMAL, 1)));
|
||||
// Legacy's PERIOD_BASIC grammar gives ModZero rank 2 and its other
|
||||
// three length modifiers rank 1, producing an effective 1-in-7 weight.
|
||||
boolean stopped = random.nextInt(7) == 0;
|
||||
@@ -81,6 +93,11 @@ public record AgeTraits(
|
||||
);
|
||||
}
|
||||
|
||||
private static ResourceLocation page(String category, Enum<?> value) {
|
||||
return ResourceLocation.fromNamespaceAndPath("rivencraft",
|
||||
category + "/" + value.name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
public boolean hasStoppedCelestialCycle() {
|
||||
return celestialPeriod == STOPPED_CELESTIAL_PERIOD;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public final class AgeWeatherService {
|
||||
return;
|
||||
}
|
||||
|
||||
AgeTraits.Weather weather = age.traits().weather();
|
||||
AgeTraits.Weather weather = age.caveTerrain() ? AgeTraits.Weather.OFF : age.traits().weather();
|
||||
if (weather == AgeTraits.Weather.NORMAL) {
|
||||
STATES.remove(level);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.level.biome.Biomes;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Focused Cave terrain writing and persisted-generation coverage for issue #14. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class CaveTerrainGameTests {
|
||||
private CaveTerrainGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void caveSkyAndWeatherPagesCostInstabilityButDoNotApply(GameTestHelper helper) {
|
||||
OrderedWriting cave = writing("terrain/cave", "biome/lush_caves", "biome/nether_wastes",
|
||||
"biome/tiled", "weather/storm", "season/eternal_winter", "period/zero",
|
||||
"celestial/normal_time");
|
||||
AgeTraits seeded = new AgeTraits(AgeTraits.BiomeDistribution.SINGLE, AgeTraits.Lighting.NORMAL,
|
||||
AgeTraits.Weather.ALWAYS, 0.5D, 0.0F, 0.25F);
|
||||
var registries = helper.getLevel().registryAccess();
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(cave, 42L, AgeGenerationProfiles.CAVE,
|
||||
seeded, registries);
|
||||
AgeTraits applied = AgeEnvironmentCompiler.apply(plan, seeded);
|
||||
helper.assertValueEqual(applied.weather(), AgeTraits.Weather.OFF, "Cave weather must be off");
|
||||
helper.assertValueEqual(applied.celestialPeriod(), 1.0D, "Cave time pages must not alter the clock");
|
||||
helper.assertValueEqual(applied.celestialPhase(), 0.0F, "Cave time pages must not set a phase");
|
||||
helper.assertValueEqual(AgeSeasonMode.select(plan, 42L, true), AgeSeasonMode.NO_SEASONS,
|
||||
"Cave seasons must be disabled even when Serene Seasons is present");
|
||||
helper.assertValueEqual(plan.biomeSelection().biomes(),
|
||||
List.of(Biomes.LUSH_CAVES.location(), Biomes.NETHER_WASTES.location()),
|
||||
"Cave writing must retain exactly its cave and Nether biomes");
|
||||
AgeInstability instability = AgeInstability.compute(plan, applied, registries);
|
||||
helper.assertValueEqual(instability.breakdown().stream()
|
||||
.filter(part -> part.reason().startsWith("cave_ignored_")).count(), 4L,
|
||||
"Each ineffective weather, season, period, and celestial page must add instability");
|
||||
helper.assertValueEqual(AgeInstability.compute(AgeGenerationPlan.load(plan.save()), applied, registries),
|
||||
instability, "Cave page penalties must survive plan reload");
|
||||
|
||||
AgeGenerationPlan surfaceCaves = AgeGenerationPlan.compile(writing("terrain/standard", "feature/caves"),
|
||||
42L, AgeGenerationProfiles.STANDARD, seeded, registries);
|
||||
helper.assertTrue(!AgeBiomeSelection.isCave(surfaceCaves.runtimeProfile())
|
||||
&& AgeFeatureSelection.resolved(surfaceCaves).stream()
|
||||
.anyMatch(choice -> AgeFeatureSelection.caves(choice.id())),
|
||||
"The separate Caves feature page must still add caves to surface terrain");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 600)
|
||||
public static void authoredCaveBiomeAndSpawnSurviveSavedDataReload(GameTestHelper helper) {
|
||||
var player = helper.makeMockServerPlayerInLevel();
|
||||
AgeReference reference = new AgeReference(UUID.randomUUID(), "Cave issue 14 " + UUID.randomUUID());
|
||||
AgeDimensionService.resolve(player, reference,
|
||||
writing("terrain/cave", "biome/nether_wastes", "biome/single")).orElseThrow();
|
||||
var registry = AgeRegistrySavedData.get(helper.getLevel());
|
||||
AgeDefinition age = registry.find(reference.id()).orElseThrow();
|
||||
helper.assertTrue(age.caveTerrain(), "The Age must retain Cave terrain");
|
||||
helper.assertTrue(age.spawn() != null && age.spawn().getY() <= 64,
|
||||
"Cave spawn search must start at Y=64, not atop the roof");
|
||||
helper.assertValueEqual(age.traits().weather(), AgeTraits.Weather.OFF,
|
||||
"A new Cave Age must persist disabled weather");
|
||||
helper.assertValueEqual(age.seasonMode(), AgeSeasonMode.NO_SEASONS,
|
||||
"A new Cave Age must persist disabled seasons");
|
||||
AgeDefinition reloaded = AgeRegistrySavedData.load(
|
||||
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||
helper.getLevel().registryAccess()).find(reference.id()).orElseThrow();
|
||||
helper.assertValueEqual(reloaded, age, "Saved Cave biome, spawn, and environment must survive reload");
|
||||
helper.assertValueEqual(reloaded.plan().biomeSelection().biomes(),
|
||||
List.of(Biomes.NETHER_WASTES.location()),
|
||||
"The generated Cave Age must preserve its authored-only Nether pool");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static OrderedWriting writing(String... pages) {
|
||||
var entries = new java.util.ArrayList<WritingEntry>();
|
||||
entries.add(WritingEntry.linkPanel());
|
||||
for (String page : pages) entries.add(WritingEntry.symbol(new SymbolReference(
|
||||
ResourceLocation.parse("rivencraft:" + page))));
|
||||
return new OrderedWriting(entries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
|
||||
/** A preceding D'ni number overrides the next celestial modifier's fixed page value. */
|
||||
final class CelestialPageValues {
|
||||
private CelestialPageValues() {}
|
||||
|
||||
static double value(SymbolSemantics semantics, Double number) {
|
||||
if (number == null) return ((SymbolSemantics.NumberValue) semantics.value()).amount();
|
||||
return switch (semantics.role()) {
|
||||
case PERIOD_MODIFIER -> number * 0.08D;
|
||||
case ANGLE_MODIFIER, PHASE_MODIFIER -> (number * 15.0D) % 360.0D;
|
||||
case SIZE_MODIFIER -> 0.25D + number * (2.75D / 24.0D);
|
||||
default -> throw new IllegalArgumentException("Not a celestial numeric modifier");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Focused core coverage without launching the storage-heavy environment archive. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class CelestialSizeGameTests {
|
||||
private CelestialSizeGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void adjacentNumberSetsNextCelestialSize(GameTestHelper helper) {
|
||||
OrderedWriting writing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||
page("number/0"), page("size/small"), page("celestial/sun"),
|
||||
page("number/24"), page("size/large"), page("celestial/moon"),
|
||||
page("number/12"), page("size/normal"), page("celestial/sun")));
|
||||
helper.assertValueEqual(WritingGrammar.parse(writing).writingInstability(), 0,
|
||||
"Adjacent number and size pages must be valid writing");
|
||||
AgeTraits seeded = AgeTraits.select(94L);
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 94L,
|
||||
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
|
||||
AgeSkyProfile sky = AgeSkyProfile.compile(plan, AgeEnvironmentCompiler.apply(plan, seeded));
|
||||
helper.assertValueEqual(sky.bodies().size(), 3, "Three authored bodies must remain distinct");
|
||||
helper.assertValueEqual(sky.bodies().get(0).size(), 0.25F, "Digit zero selects minimum size");
|
||||
helper.assertValueEqual(sky.bodies().get(1).size(), 3.0F, "Digit 24 selects maximum size");
|
||||
helper.assertValueEqual(sky.bodies().get(2).size(), 1.625F, "Digit 12 selects midpoint size");
|
||||
helper.assertValueEqual(AgeSkyProfile.compile(AgeGenerationPlan.load(plan.save()),
|
||||
AgeEnvironmentCompiler.apply(plan, seeded)), sky,
|
||||
"Numbered sizes must survive an Age-plan save/load round trip");
|
||||
|
||||
OrderedWriting plain = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||
page("size/small"), page("celestial/sun"),
|
||||
page("size/normal"), page("celestial/moon"),
|
||||
page("size/large"), page("celestial/sun")));
|
||||
AgeGenerationPlan plainPlan = AgeGenerationPlan.compile(plain, 95L,
|
||||
AgeGenerationProfiles.STANDARD, AgeTraits.select(95L), helper.getLevel().registryAccess());
|
||||
AgeSkyProfile plainSky = AgeSkyProfile.compile(plainPlan,
|
||||
AgeEnvironmentCompiler.apply(plainPlan, AgeTraits.select(95L)));
|
||||
helper.assertValueEqual(plainSky.bodies().get(0).size(), 0.5F, "Small retains its fixed value");
|
||||
helper.assertValueEqual(plainSky.bodies().get(1).size(), 1.0F, "Normal retains its fixed value");
|
||||
helper.assertValueEqual(plainSky.bodies().get(2).size(), 2.0F, "Large retains its fixed value");
|
||||
|
||||
OrderedWriting separated = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||
page("number/24"), page("direction/clockwise"),
|
||||
page("size/large"), page("celestial/sun")));
|
||||
helper.assertValueEqual(WritingGrammar.parse(separated).writingInstability(), 100,
|
||||
"An intervening page must not allow a number to modify size");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static WritingEntry page(String path) {
|
||||
return WritingEntry.symbol(new SymbolReference(ResourceLocation.fromNamespaceAndPath("rivencraft", path)));
|
||||
}
|
||||
}
|
||||