Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda19de43c | ||
|
|
0fb2f7c1bb | ||
|
|
4f07f675a4 | ||
|
|
b8be99606f | ||
|
|
98595c83ea | ||
|
|
e7e2cf7e5a | ||
|
|
77f15f8599 | ||
|
|
2b2605b277 | ||
|
|
2f1e6bd6f2 |
No files matched your search
@@ -1,289 +1,117 @@
|
||||
# Rivencraft
|
||||
|
||||
Rivencraft is a clean, incremental port of the ideas and gameplay of
|
||||
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
|
||||
1.21.1 on NeoForge.
|
||||
Write a book. Name an Age. Step into a world that did not exist before.
|
||||
|
||||
The bootstrap, books/linking, Ages, writing-system, and Age-grammar milestones are complete.
|
||||
The writing system uses a data-pack symbol catalog and persistent
|
||||
ordered-writing model. Linking Books and Descriptive Books create persistent
|
||||
Ages from either authored supported symbols or seeded random completion.
|
||||
Milestone 6 instability work is in progress: Ages persist frozen instability
|
||||
scores and provider plans, and the bounded runtime implements status effects,
|
||||
colored decay, lightning, explosions, meteors, burning, and erosion. The
|
||||
[Milestone 5 acceptance record](docs/MILESTONE_5_ACCEPTANCE.md) lists the
|
||||
supported grammar, optional integrations, and limits; the
|
||||
[instability runtime policy](docs/INSTABILITY_RUNTIME.md) records the current
|
||||
effects and modern safety boundaries.
|
||||
Rivencraft brings the books, writing stations, symbols, and unpredictable Ages
|
||||
of [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to
|
||||
Minecraft 1.21.1 on NeoForge. Mystcraft veterans will recognize the writing
|
||||
flow, D'ni-inspired glyphs, libraries, crystals, and instability. Rivencraft
|
||||
also lets you author modern terrain, biomes, structures, seasons, and celestial
|
||||
bodies. It is an active pre-public-release project, not a byte-for-byte copy of
|
||||
the 1.12.2 mod.
|
||||
|
||||
Milestone 4 now includes stackable blank Pages plus server-created symbol,
|
||||
unresolved-symbol, and baseline link-panel states. Page visuals follow the
|
||||
legacy runtime-composition rules. Craft an Ink Vial from two black dye, a glass
|
||||
bottle, and a water bucket; the bucket is returned. Hold vanilla paper in one
|
||||
hand and use the Ink Vial in the other to prepare a baseline Link Panel Page.
|
||||
Each vial holds 16 ink units, and the default link-panel cost is two units.
|
||||
The spent vial returns a glass bottle. Server owners can adjust the symbol-copy
|
||||
and link-panel costs in the `writing` section of `rivencraft-server.toml`.
|
||||
The Ink Mixer now offers a Legacy-style station for producing Link Panels:
|
||||
insert paper and Ink Vials, then take a Link Panel from the result slot. Each
|
||||
vial fills its 16-unit basin and returns a glass bottle; taking one Link Panel
|
||||
uses the full vial. No server tick is needed. The earlier paper-in-one-hand
|
||||
method still uses the configurable link-panel ink cost. Ink Mixer
|
||||
additives can roll Disarm, Maintain Momentum, and Intra Linking onto a Link
|
||||
Panel. Descriptive Books retain Disarm and Momentum; Intra Linking is only
|
||||
meaningful for same-dimension travel with a Linking Book.
|
||||
TerraBlender 1.21.1 integration is optional: registered modded biomes receive
|
||||
Biome pages, with cave and Nether biomes in a separate Cave Biome creative tab.
|
||||
TerraBlender Overworld/Nether regions extend compatible Age
|
||||
biome pools. See [the compatibility matrix](docs/TERRABLENDER_COMPATIBILITY.md)
|
||||
for supported versions and in-game verification.
|
||||
Serene Seasons 1.21.1 can maintain separate season progress in an Age dimension.
|
||||
Rivencraft automatically whitelists loaded Ages without a restart when Serene is installed;
|
||||
season pages can instead opt an Age out or hold it in one season.
|
||||
See [the optional compatibility guide](docs/SERENE_SEASONS_COMPATIBILITY.md)
|
||||
for the supported manual configuration and current limits; Rivencraft does not
|
||||
require Serene Seasons or GlitchCore.
|
||||
The Writing Desk can copy a selected written page from its collection tabs.
|
||||
The Writing Desk is now craftable from planks, a glass bottle, and a feather.
|
||||
It occupies two adjacent blocks, with one shared inventory; loaded paper and
|
||||
the selected page appear on its Legacy-inspired model.
|
||||
Desks placed before 0.3.6.1 keep their old one-block footprint until broken
|
||||
and placed again; leave an adjacent space when replacing one.
|
||||
Its 88 collection tabs, paper input, ink vial input, empty glass-bottle return,
|
||||
16-unit ink meter, and inventory persist
|
||||
through world saves; breaking it drops stored contents. The desk screen is a
|
||||
functional foundation using Legacy's original desk and scrollbar textures.
|
||||
Four tabs are visible at once; the controls above and below them move by four
|
||||
(1–4, 5–8, through 85–88). Each viewer can browse a different group. Existing
|
||||
four-tab desks retain their collections and supplies when upgraded.
|
||||
With a notebook selected, pick up a Page from your inventory and click an empty
|
||||
position on the left surface to store one page there. Clear search/filter/sort
|
||||
and turn off `ALL` to place pages at specific positions; occupied positions are
|
||||
never overwritten. A notebook tab exposes all 27 notebook positions (scroll to
|
||||
reach the rest); a loose-page tab exposes only its one occupied position.
|
||||
Empty tabs have no page storage until a notebook or page is put in the tab slot.
|
||||
Click a page on the desk surface to select it for copying. Drag a page from one
|
||||
notebook position to an empty position to move it, or onto another page to swap
|
||||
their positions. This also works between notebooks shown in the `ALL` view.
|
||||
Drag a page out of the panel to turn it back into an item. Drop it over a player
|
||||
inventory slot or desk tab to place it there; a tab with a notebook adds it to
|
||||
the first open position. Dropping elsewhere leaves the page on the cursor for
|
||||
normal inventory placement. The source notebook or tab loses the page only after
|
||||
a valid drop.
|
||||
Put vanilla paper in the
|
||||
upper-left slot and Ink Vials in the upper-right; a copy appears in the
|
||||
lower-left result slot when the selected page is valid. Taking it consumes one
|
||||
paper and, by default, one ink. An available vial refills an empty ink meter
|
||||
when inserted or when copying uses its last ink, and returns a vanilla glass
|
||||
bottle to the lower-right slot. Removing a bottle also retries a blocked refill.
|
||||
Refilling waits if that slot is full or contains another item. The `AZ` control sorts
|
||||
visible pages by name; `ALL` shows pages held across every desk tab. Search and
|
||||
category filtering narrow the view without granting new pages, and cross-tab
|
||||
copy selection is checked by the server.
|
||||
Selecting a stored page on the desk surface shows its Legacy-proportioned
|
||||
portrait preview and name above the player inventory.
|
||||
## Getting started
|
||||
|
||||
## Book Binder
|
||||
1. Make an Ink Vial from two black dyes, a glass bottle, and a water bucket.
|
||||
Craft an Ink Mixer, Writing Desk, and Book Binder.
|
||||
2. Put paper and an Ink Vial in the Ink Mixer and take a Link Panel Page. Add
|
||||
ingredients to the basin first if you want a chance of link modifiers.
|
||||
3. Put leather or an empty Notebook and the Link Panel in the Book Binder.
|
||||
Name the Age and take the Descriptive Book. A book with only a Link Panel
|
||||
creates a seeded random Age; add Symbol Pages to author its features.
|
||||
4. Open the book to create its Age, then click the destination preview to
|
||||
travel. Bring a bound Linking Book for the return trip: automatic Return
|
||||
Links are off by default.
|
||||
|
||||
For a new survival Age: craft an Ink Vial, an Ink Mixer, a Writing Desk, and a Book Binder.
|
||||
Put vanilla paper and an Ink Vial in the Ink Mixer to make a Link Panel
|
||||
Page. Place leather (or an empty Notebook) and the Link Panel in the Book Binder,
|
||||
enter an Age name, and take the finished Descriptive Book. Written symbol Pages
|
||||
are optional; without them, the book creates a random Age. To duplicate a Page
|
||||
you already own, store it in a Writing Desk tab, select it, supply paper and ink,
|
||||
and take the copied Page from the output. A Notebook can hold and transfer a
|
||||
larger ordered collection of Pages between desks and the binder.
|
||||
Find new Symbol Pages in Age libraries, or copy pages you already own with
|
||||
paper and ink at the Writing Desk. The Notebook stores ordered pages, including
|
||||
intentional gaps. Written pages can control terrain, biome distribution,
|
||||
weather, lighting, seasons, sky color, suns and moons, decorations, structures,
|
||||
and more. Incorrect or excessive writing can make an Age unstable; instability
|
||||
may bring decay, lightning, explosions, meteors, burning, erosion, and other
|
||||
hazards. Some combinations are still unsupported and the Binder reports them
|
||||
before consuming ingredients.
|
||||
|
||||
With JEI installed, Rivencraft displays the Ink Mixer operation in its own
|
||||
category and the hand-use, copying, binding, and Linking Book crafting flows in a Writing category.
|
||||
The Link Panel and individual symbol Pages, including registered vanilla biome
|
||||
pages, appear as separately searchable JEI ingredients.
|
||||
Creative inventory separates Link Pages and each symbol category into their own
|
||||
tabs, including distinct Biome and Biome Generation tabs. Blank Pages are hidden
|
||||
from creative inventory and JEI.
|
||||
JEI is optional; vanilla crafting
|
||||
recipes and all writing interactions work without it. For a development client
|
||||
with JEI, run `./gradlew runClient -PwithJei=true`.
|
||||
Place a Rivencraft book or page on a vanilla Lectern to convert it to a Book
|
||||
Stand. Crystal formations yield shards for Crystal Blocks. A connected crystal
|
||||
frame with a Book Receptacle and a bound book forms a portal; frames can stand
|
||||
upright or lie flat. Libraries, Book Stands, and portals use Legacy-inspired
|
||||
assets and sounds. Handheld travel drops the used book at the departure point,
|
||||
so plan how you will retrieve it.
|
||||
|
||||
Four Crystal Shards craft one Crystal Block; eight Crystal Blocks around an
|
||||
empty crafting-grid center make one Book Receptacle. Picking up a shard unlocks
|
||||
the block recipe, and picking up a block unlocks the Receptacle recipe. The
|
||||
existing Ink Vial recipe uses two black dyes, a glass bottle, and a water
|
||||
bucket; the empty bucket stays in the crafting grid. These JSON recipes appear
|
||||
in JEI when it is installed and can be replaced by a data pack at
|
||||
`data/rivencraft/recipe/<recipe_name>.json`. To test a replacement, use a fresh
|
||||
world or `/reload`, then check the result in a crafting table and vanilla
|
||||
Crafter. A vanilla Lectern converts to a Rivencraft Book Stand when a Rivencraft
|
||||
book or page is placed on it; this in-world interaction has no JEI guide.
|
||||
With Create installed, Crystal Blocks and Book Receptacles have an optional Ponder scene
|
||||
showing crystal-frame construction, book activation, and a parrot crossing the portal. Use the Ponder shortcut
|
||||
while hovering either item; Rivencraft does not require Create to run.
|
||||
Crystal frames may stand vertically or lie flat: a 3×3 Crystal Block ring with
|
||||
an empty center forms a horizontal portal when a Book Receptacle is attached to
|
||||
the top or underside of one of its blocks. Insert a bound Linking or Descriptive
|
||||
Book to activate it; the book remains visible on the receptacle. A Linking Book aimed at its
|
||||
own dimension still opens a visual portal, but travel requires Intra Linking.
|
||||
## Requirements and optional integrations
|
||||
|
||||
Sky writing has shared Red, Green, Blue, White, and Black pages plus 25 D'ni
|
||||
number pages (0–24). Put colors before a Sky, Sun, or Moon page to color that
|
||||
target; trailing colors tint the sky. A number immediately before a color sets
|
||||
its strength to number × 10. Without a number, a color gets a seeded random
|
||||
strength from 0–24. RGB contributions add in any order, White brightens all
|
||||
channels, Black subtracts from all channels, and omitted channels remain zero.
|
||||
The resulting sky color also tints book previews and fallback portal surfaces.
|
||||
An Age may have no suns or moons at all; Static Sky animates with fine-grained
|
||||
television-like noise. Sun and Moon pages add
|
||||
independent bodies; preceding color, size, angle, period, phase, and direction
|
||||
pages configure the next body. The third and each subsequent sun add increasing
|
||||
instability. Overlapping moon and sun discs produce an eclipse: the moon darkens,
|
||||
the sun's color brightens at close alignment, and world and sky lighting dim.
|
||||
Newly generated page-free Ages also choose a deterministic sky,
|
||||
color, and celestial arrangement from their Age seed; older saved Ages retain
|
||||
their previous sky.
|
||||
Install the same gameplay mods on the client and server:
|
||||
|
||||
Craft a Book Binder from three iron ingots and five planks. Put leather or an
|
||||
empty Notebook in the left cover slot. Enter a title, then place a Link Panel
|
||||
Page first in the horizontal page strip. Click a page in the strip with a
|
||||
written Page on the cursor to insert it before that position; click it with an
|
||||
empty cursor to take it out. The arrows or mouse wheel scroll through up to
|
||||
27 written Pages. Clicking the strip with a filled Notebook imports its Pages
|
||||
at that position and leaves the emptied Notebook on the cursor. The right slot
|
||||
outputs a Descriptive Book once the title, cover, and Link Panel are present.
|
||||
Inputs are consumed only when the result is taken.
|
||||
- Minecraft **1.21.1**, NeoForge **21.1.264 or newer in the 21.1 line**, and
|
||||
Java 21.
|
||||
- **Dynamic Dimensions AR Edition 0.9.1** and its **Bad Packets 0.8.2**
|
||||
dependency. These are required for Age dimensions.
|
||||
|
||||
The completed book receives its Age identity when assembled. With only a Link
|
||||
Panel, it describes a randomly generated Age. Authored writing is compiled once
|
||||
into a versioned, server-owned Age plan. The plan freezes the Age seed, resolved
|
||||
symbol meanings, page order, and runtime profile, so later data-pack changes do
|
||||
not reinterpret an existing Age. An authored terrain page selects its runtime
|
||||
profile. Built-in pages cover Standard, Amplified, Cave, Island, Flat, and Void;
|
||||
authored biome, environment, and supported feature pages also affect new Ages.
|
||||
The
|
||||
writing is also stored on the book; the Descriptive Book screen's turned pages
|
||||
show ordered symbols and unresolved IDs. The Descriptive Book screen follows
|
||||
Legacy's book spread: the title and Age details are on the left, the destination
|
||||
preview is on the right, and clicks in the bottom corners turn pages. Ordered
|
||||
symbol pages appear before the final blank spread with their runtime-rendered
|
||||
pictograms. Link-panel-only books use Rivencraft glyphs for their generated
|
||||
Age description.
|
||||
Rivencraft works without any of these optional integrations:
|
||||
|
||||
## Linking Book
|
||||
| Mod | What it adds |
|
||||
| --- | --- |
|
||||
| JEI | Searchable Pages and guides for the Ink Mixer, Writing Desk, Book Binder, and Linking Book crafting. |
|
||||
| Create (bundles Ponder) | Ponder book snapshots and a crystal-portal construction guide. |
|
||||
| Immersive Portals | Live destination views in book previews and crystal portals. In `AUTO` preview mode it takes priority over Ponder. |
|
||||
| TerraBlender | Eligible biomes from mods using its Overworld/Nether regions become Age biome candidates and Pages. Biomes O' Plenty is one example; it is not hard-coded or required. |
|
||||
| Serene Seasons (with GlitchCore) | Independent per-Age seasons, including normal seasons, no seasons, and four eternal-season modes. |
|
||||
| Ruins | Authorable Ruins-template Pages and Ruins structures in Ages. |
|
||||
|
||||
Craft an unwritten Linking Book from one Link Panel Page and one leather in any
|
||||
two crafting-grid positions. An unbound book opens a screen where it can be named and bound to the
|
||||
current location. A bound book opens its destination screen and travels when
|
||||
its preview panel is clicked. The destination is stored on the item and
|
||||
survives inventory and world saves. Travel only succeeds if the target
|
||||
dimension is loaded and a non-fluid, passable landing position can be found
|
||||
nearby.
|
||||
The Linking Book uses the same Legacy page layout and bottom-corner page turns
|
||||
as the Descriptive Book, but without the Descriptive Book's gold cover edges.
|
||||
Both screens play Minecraft's book-page-turn sound when a page is turned.
|
||||
The optional builds used in the latest development checks are JEI
|
||||
19.69.0.457, Create 6.0.10, Immersive Portals 6.0.7, TerraBlender 4.1.0.8,
|
||||
Serene Seasons 10.1.0.9, and Ruins 1.21.1.2NF. See the
|
||||
[compatibility review](docs/MILESTONE_7_COMPATIBILITY_REVIEW.md) for the
|
||||
evidence and limits of those combinations.
|
||||
|
||||
Successful book travel plays Mystcraft Legacy's original linking sound at both
|
||||
the departure and arrival locations.
|
||||
Without Create or Immersive Portals, book previews use Rivencraft's built-in
|
||||
Legacy-style animation. Ecliptic Seasons is **not supported**: its 1.21.1 API
|
||||
does not offer the live dimension/season control Rivencraft needs.
|
||||
|
||||
Right-click an empty vanilla lectern with a Rivencraft book to convert it into
|
||||
a Book Stand and place the book on it. Use a Linking Book on the stand by
|
||||
right-clicking with an empty hand. Sneak-right-click with an empty hand to take
|
||||
the book back and restore the vanilla lectern. Breaking a Book Stand drops both
|
||||
the stored book and a vanilla lectern.
|
||||
## Saves, settings, and current limits
|
||||
|
||||
Bound Linking Books show a rotating Ponder snapshot when Create is installed.
|
||||
Without Create, they use a built-in animated linking panel tinted by the
|
||||
destination sky. The server independently validates every link.
|
||||
Ages and bound books use persistent UUIDs. Back up the **entire world folder**
|
||||
before changing mod versions, data packs, or optional biome mods: the Age
|
||||
registry and dimension data must be restored together. Rivencraft preserves
|
||||
known older save formats where possible, but pre-public-release worlds are not
|
||||
promised universal compatibility. Missing custom symbols retain their IDs in
|
||||
books; missing registry entries or dimension providers can still prevent an
|
||||
Age from loading. See [current implementation](docs/CURRENT_IMPLEMENTATION.md)
|
||||
for supported behavior and deliberate limits.
|
||||
|
||||
## Descriptive Book
|
||||
Server settings include `descriptive_books.return_link_mode` (default
|
||||
`DISABLED`), `libraries.loot_mode` (`LEGACY` or `AGE_ONLY`), writing ink costs,
|
||||
season enrollment, and instability controls. The client setting
|
||||
`book_preview.mode` chooses `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
|
||||
An operator can inspect Ages and instability with `/rivencraft age` and find
|
||||
libraries with `/rivencraft library nearest`. The creative-only Link Modifier
|
||||
can edit link properties and retire an Age; use it carefully and keep backups.
|
||||
Binder status messages are off by default. An OP can run
|
||||
`/rivencraft binder messages enable` or `/rivencraft binder messages disable`
|
||||
to control them; only OPs see the diagnostics even when enabled.
|
||||
|
||||
Open a page-free Descriptive Book, name its Age, and press Create. Rivencraft
|
||||
assigns the book a permanent Age UUID, selects a deterministic base terrain,
|
||||
creates a dedicated runtime dimension, and prepares a five-by-five cobblestone
|
||||
arrival platform at the
|
||||
highest suitable nearby terrain. Solid blocks and tree leaves are valid
|
||||
footing. Candidate columns are scanned from the top down so cave floors are not
|
||||
selected, and fluid-covered columns are excluded. If no suitable surface is
|
||||
available, the platform is generated at Y=63. Reopen the book and click its preview to travel.
|
||||
The book cannot reopen while its Age is being generated, whether held or on a
|
||||
Book Stand. Once creation finishes, reopening shows the written book.
|
||||
Copies of the written book continue to resolve to the same persistent Age
|
||||
across server restarts.
|
||||
## Project and development
|
||||
|
||||
New Age dimension folders use a Minecraft-safe lowercase form of the assigned
|
||||
name. Conflicts receive incrementing suffixes such as `_2` and `_3`; existing
|
||||
Age folders are never renamed.
|
||||
This repository is the development source. See the [current implementation](docs/CURRENT_IMPLEMENTATION.md),
|
||||
[milestone summaries](docs/MILESTONES.md), [technical document index](docs/README.md),
|
||||
and [issues](https://git.paddedcell.org/dnorberg/rivencraft/issues).
|
||||
Help build the [Rivencraft wiki](https://git.paddedcell.org/dnorberg/rivencraft/wiki)
|
||||
with guides for Ages, pages, and crafting, or open an issue with documentation
|
||||
suggestions and corrections.
|
||||
Milestone 7 is complete in 1.0.0.2. Its [acceptance record](docs/MILESTONE_7_ACCEPTANCE.md)
|
||||
and [regression matrix](docs/MILESTONE_7_REGRESSION.md) distinguish automated
|
||||
checks from manual evidence and known limitations.
|
||||
|
||||
Page-free Ages deterministically select one of six base terrains inspired by
|
||||
Mystcraft Legacy: Standard, Amplified, Island, Cave, Flat, or Void. The chosen
|
||||
profile is saved with the Age and does not change when the server restarts.
|
||||
|
||||
Linking creates a bound Return Link to the exact source position and orientation
|
||||
according to the configured Return Link policy.
|
||||
|
||||
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
|
||||
server config to `DISABLED` (default), `ON_CREATION`, or `ALWAYS`.
|
||||
Age creation, persistence, lifecycle commands, configuration, backups, and
|
||||
administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
|
||||
Data-pack runtime profiles are documented in
|
||||
[docs/AGE_PROFILES.md](docs/AGE_PROFILES.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Java 21 JDK
|
||||
- Minecraft 1.21.1
|
||||
- NeoForge 21.1.264 or newer in the 21.1 line
|
||||
- Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server)
|
||||
|
||||
Create is optional. When installed, Rivencraft uses its Ponder renderer for
|
||||
book previews when Immersive Portals is absent. Immersive Portals is also
|
||||
optional and takes priority in the default `AUTO` mode, providing a live
|
||||
rotating destination view. Its Explore button enables WASD and jump/sneak
|
||||
camera movement plus left-drag camera look; right-click returns to the rotating
|
||||
view. Without either integration, Rivencraft uses its built-in animated linking
|
||||
panel. Clients can set `book_preview.mode` in
|
||||
`rivencraft-client.toml` to `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
|
||||
Unavailable optional renderers safely fall back to `LEGACY`.
|
||||
The complete backend matrix, supported combinations, cleanup behavior, and
|
||||
known upstream limits are documented in
|
||||
[docs/PREVIEW_COMPATIBILITY.md](docs/PREVIEW_COMPATIBILITY.md).
|
||||
|
||||
## Build and run
|
||||
|
||||
```shell
|
||||
```sh
|
||||
./gradlew build
|
||||
./gradlew runClient
|
||||
./gradlew runGameTestServer
|
||||
```
|
||||
|
||||
The built mod is written to `build/libs/`.
|
||||
The default dedicated-server GameTest run is deliberately small. Use
|
||||
`./gradlew runGameTestServer -PwithArchivedGameTests=true` for the complete
|
||||
regression suite before a release or when investigating a related issue. See
|
||||
[docs/GAMETESTS.md](docs/GAMETESTS.md).
|
||||
The JAR is written to `build/libs/`. Development runs need the required mods;
|
||||
see [GameTest guidance](docs/GAMETESTS.md) for focused archived suites.
|
||||
Versions use `x.y.z.b`: major release, completed milestone, release slice,
|
||||
then fixes/additional work within that slice.
|
||||
|
||||
## Versioning
|
||||
|
||||
Rivencraft versions use `x.y.z.b`:
|
||||
|
||||
- `x` changes for a major public release.
|
||||
- `y` changes when a development milestone is completed.
|
||||
- `z` changes for each release slice within the current milestone.
|
||||
- `b` changes for bug fixes and additional work within that release slice.
|
||||
|
||||
The build component resets to zero when a new release slice begins. Completing
|
||||
a milestone increments `y` and resets both `z` and `b` to zero. For example,
|
||||
milestone 2 development can progress from `0.1.1.0` to `0.1.1.1`, then
|
||||
`0.1.2.0`; completing the milestone produces `0.2.0.0`.
|
||||
|
||||
## Porting approach
|
||||
|
||||
Mystcraft Legacy targets Forge for Minecraft 1.12.2. Rivencraft therefore
|
||||
ports one subsystem at a time onto current registries, codecs, data packs, and
|
||||
NeoForge APIs instead of importing the legacy source tree wholesale.
|
||||
|
||||
See [docs/PORTING.md](docs/PORTING.md) for the roadmap, live Gitea tracking
|
||||
links, architectural direction, and provenance rules.
|
||||
|
||||
## License
|
||||
|
||||
Rivencraft is licensed under LGPL-3.0-or-later. Mystcraft Legacy is also
|
||||
LGPL-3.0; copied or adapted upstream code must retain its copyright and license
|
||||
notices. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
Rivencraft is licensed under LGPL-3.0-or-later. Legacy-derived material is
|
||||
credited in [NOTICE](NOTICE) and [asset provenance](docs/ASSET_PROVENANCE.md).
|
||||
@@ -245,6 +245,11 @@ dependencies {
|
||||
// Keep them on the compile classpath without forcing either mod at runtime.
|
||||
compileOnly "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}"
|
||||
compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}"
|
||||
// Ruins is optional; its pre-spawn event is only registered when Ruins is loaded.
|
||||
compileOnly 'curse.maven:ruins-227873:6391825'
|
||||
if (providers.gradleProperty('withRuins').map { it.toBoolean() }.orElse(false).get()) {
|
||||
localRuntime 'curse.maven:ruins-227873:6391825'
|
||||
}
|
||||
// TerraBlender's region API is only used by the guarded compat adapter.
|
||||
compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
|
||||
if (providers.gradleProperty('withTerraBlender').map { it.toBoolean() }.orElse(false).get()) {
|
||||
|
||||
|
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: 43 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
|
||||
@@ -101,8 +102,8 @@ Use this sequence when a book cannot load an existing Age:
|
||||
matching dimension data and authoritative world saved data from the same
|
||||
backup generation. Do not combine them from different backups.
|
||||
|
||||
There is intentionally no delete command in Milestone 3. Do not manually
|
||||
delete an Age definition or rename its dimension folder: books may exist in
|
||||
inventories, containers, Book Stands, or unloaded chunks, and would be
|
||||
orphaned. `/rivencraft age unload` is the supported reversible maintenance
|
||||
operation.
|
||||
Do not manually delete an Age definition or rename its dimension folder:
|
||||
books may exist in inventories, containers, Book Stands, or unloaded chunks.
|
||||
`/rivencraft age unload` remains the reversible maintenance operation. The
|
||||
Link Modifier's retirement control is not reversible through a book UI;
|
||||
recover its archived data and registry together from a full-world backup.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -32,9 +32,10 @@ The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Le
|
||||
| `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. |
|
||||
| `season` | `no_seasons`, `seasons`, `eternal_winter`, `eternal_summer`, `eternal_fall`, or `eternal_spring`. Seasonal effects other than No Seasons require optional Serene Seasons. |
|
||||
| `celestial` | `normal_time` (the first representable single-cycle behavior). |
|
||||
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. |
|
||||
| `angle_modifier` | JSON number in `[0, 360)` degrees. |
|
||||
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler will convert to turns. |
|
||||
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. An immediately preceding D'ni number page overrides it with digit × 0.08. |
|
||||
| `angle_modifier` | JSON number in `[0, 360)` degrees. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
|
||||
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler converts it to turns. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
|
||||
| `size_modifier` | JSON scale in `[0.25, 3]`. An immediately preceding D'ni number page overrides it with a linear 0.25×–3× scale: digit 0 is 0.25× and digit 24 is 3×. Without a number, Small/Normal/Large remain 0.5×/1×/2×. |
|
||||
| `block` | Canonical namespaced material ID for a compatible following feature; registered full-collision blocks and source fluids receive generated Pages. |
|
||||
| `feature` | Canonical namespaced feature-effect ID; the compiler must recognize and validate it. This includes supported decorations and structures. |
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ mod_name=Rivencraft
|
||||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||
mod_license=LGPL-3.0-or-later
|
||||
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
||||
mod_version=0.6.8.10
|
||||
mod_version=1.0.1.1
|
||||
immersive_portals_version=6.0.7
|
||||
terrablender_version=4.1.0.8
|
||||
serene_seasons_version_id=pHEgQQUE
|
||||
|
||||
@@ -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();
|
||||
@@ -82,6 +84,8 @@ public final class Rivencraft {
|
||||
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.server.ServerStoppingEvent event) ->
|
||||
RuinsCompatibility.onServerStopping());
|
||||
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
|
||||
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerStopping);
|
||||
|
||||
@@ -36,6 +36,7 @@ import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.DirectionProperty;
|
||||
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||
@@ -44,10 +45,11 @@ import net.minecraft.world.phys.shapes.VoxelShape;
|
||||
public final class BookReceptacleBlock extends BaseEntityBlock {
|
||||
public static final MapCodec<BookReceptacleBlock> CODEC = simpleCodec(BookReceptacleBlock::new);
|
||||
public static final DirectionProperty FACING = BlockStateProperties.FACING;
|
||||
public static final BooleanProperty HAS_BOOK = BlockStateProperties.HAS_BOOK;
|
||||
|
||||
public BookReceptacleBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
|
||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(HAS_BOOK, false));
|
||||
}
|
||||
|
||||
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||
@@ -64,7 +66,7 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
||||
};
|
||||
}
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||
builder.add(FACING);
|
||||
builder.add(FACING, HAS_BOOK);
|
||||
}
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new BookReceptacleBlockEntity(pos, state);
|
||||
@@ -153,7 +155,7 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
||||
if (!state.is(replacement.getBlock()) && level instanceof ServerLevel server) {
|
||||
CrystalPortalNetwork.shutdown(server, pos);
|
||||
if (level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder) {
|
||||
ItemStack book = holder.takeBook();
|
||||
ItemStack book = holder.takeBookOnBreak();
|
||||
if (!book.isEmpty()) level.addFreshEntity(new ItemEntity(level, pos.getX()+0.5, pos.getY()+0.5,
|
||||
pos.getZ()+0.5, book));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||
import net.dnorberg.rivencraft.book.AgeEnvironmentCompiler;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.minecraft.nbt.NbtOps;
|
||||
import net.minecraft.core.BlockPos;
|
||||
@@ -52,18 +54,36 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
}
|
||||
|
||||
public ItemStack takeBook() {
|
||||
return removeBook(true);
|
||||
}
|
||||
|
||||
/** The block is already being replaced, so do not write its state back into the world. */
|
||||
public ItemStack takeBookOnBreak() {
|
||||
return removeBook(false);
|
||||
}
|
||||
|
||||
private ItemStack removeBook(boolean notify) {
|
||||
ItemStack result = book;
|
||||
book = ItemStack.EMPTY;
|
||||
destination = null;
|
||||
skyColor = 0xFFFFFF;
|
||||
sync();
|
||||
if (notify) sync();
|
||||
else setChanged();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void sync() {
|
||||
setChanged();
|
||||
if (level != null && !level.isClientSide)
|
||||
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
|
||||
if (level instanceof ServerLevel server) {
|
||||
BlockState state = server.getBlockState(worldPosition);
|
||||
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get())) return;
|
||||
boolean hasBook = !book.isEmpty();
|
||||
if (state.getValue(BookReceptacleBlock.HAS_BOOK) != hasBook) {
|
||||
state = state.setValue(BookReceptacleBlock.HAS_BOOK, hasBook);
|
||||
server.setBlock(worldPosition, state, 2);
|
||||
}
|
||||
server.sendBlockUpdated(worldPosition, state, state, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -98,6 +118,10 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state,
|
||||
BookReceptacleBlockEntity holder) {
|
||||
if (!(world instanceof ServerLevel level)) return;
|
||||
// Reconcile receptacles saved before HAS_BOOK existed, including an
|
||||
// occupied receptacle restored from an older pre-release world.
|
||||
boolean hasBook = !holder.book.isEmpty();
|
||||
if (state.getValue(BookReceptacleBlock.HAS_BOOK) != hasBook) holder.sync();
|
||||
if (holder.book.isEmpty() || (!holder.restoreView && level.getGameTime() % 200 != 0)) return;
|
||||
// Startup maintenance must never turn a saved portal off while its
|
||||
// chunks and block entities are still arriving. Structural edits are
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.util.Random;
|
||||
import java.util.Set;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
|
||||
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.core.Holder;
|
||||
@@ -34,6 +35,12 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
|
||||
|
||||
public static AgeBiomeSelection compile(WritingGrammar.Result writing, AgeTraits traits,
|
||||
AgeRuntimeProfile profile, long seed, RegistryAccess registries) {
|
||||
return compile(writing, traits, profile, seed, registries, RandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static AgeBiomeSelection compile(WritingGrammar.Result writing, AgeTraits traits,
|
||||
AgeRuntimeProfile profile, long seed, RegistryAccess registries,
|
||||
RandomPagePolicy policy) {
|
||||
List<ResourceLocation> authored = writing.symbols().stream()
|
||||
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.BIOME)
|
||||
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList();
|
||||
@@ -67,10 +74,26 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
|
||||
+ " or a TerraBlender Nether region");
|
||||
}
|
||||
}
|
||||
List<ResourceLocation> availableBiomes = eligible(registries, profile);
|
||||
if (availableBiomes.isEmpty()) throw new IllegalArgumentException("No eligible biomes for terrain " + profile.noiseSettings());
|
||||
List<ResourceLocation> terrainBiomes = eligible(registries, profile);
|
||||
if (terrainBiomes.isEmpty()) throw new IllegalArgumentException("No eligible biomes for terrain " + profile.noiseSettings());
|
||||
List<ResourceLocation> availableBiomes = distribution == AgeTraits.BiomeDistribution.NATIVE
|
||||
? terrainBiomes : new ArrayList<>(policy.eligible(terrainBiomes, BiomeSymbolPages::symbolId));
|
||||
if (distribution != AgeTraits.BiomeDistribution.NATIVE
|
||||
&& new LinkedHashSet<>(availableBiomes).size() < minimum(distribution)) {
|
||||
ResourceLocation preferred = profile.terrain() == AgeRuntimeProfile.Terrain.VOID
|
||||
? ResourceLocation.withDefaultNamespace("the_void")
|
||||
: cave ? ResourceLocation.withDefaultNamespace("deep_dark")
|
||||
: ResourceLocation.withDefaultNamespace("plains");
|
||||
if (terrainBiomes.contains(preferred) && !availableBiomes.contains(preferred))
|
||||
availableBiomes.add(preferred);
|
||||
for (ResourceLocation fallback : terrainBiomes) {
|
||||
if (new LinkedHashSet<>(availableBiomes).size() >= minimum(distribution)) break;
|
||||
if (!availableBiomes.contains(fallback)) availableBiomes.add(fallback);
|
||||
}
|
||||
}
|
||||
if (distribution == AgeTraits.BiomeDistribution.NATIVE) {
|
||||
if (!authored.isEmpty()) throw new IllegalArgumentException("Native biome distribution cannot use biome pages");
|
||||
// Native noise biomes are supplied by Minecraft's preset, not chosen as Pages.
|
||||
return new AgeBiomeSelection(distribution, profile.terrain() == AgeRuntimeProfile.Terrain.FLAT
|
||||
? List.of(ResourceLocation.withDefaultNamespace("plains"))
|
||||
: isCave(profile) ? availableBiomes : presetBiomes(registries, profile));
|
||||
|
||||
@@ -23,7 +23,8 @@ public record AgeDefinition(
|
||||
@Nullable AgeSeasonMode seasonMode,
|
||||
List<AgeFeatureSelection.Choice> randomFeatures,
|
||||
AgeInstability instability,
|
||||
AgeInstabilityPlan instabilityPlan
|
||||
AgeInstabilityPlan instabilityPlan,
|
||||
RandomPagePolicy randomPagePolicy
|
||||
) {
|
||||
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, "random_vanilla"
|
||||
@@ -39,6 +40,7 @@ public record AgeDefinition(
|
||||
Objects.requireNonNull(traits, "traits");
|
||||
Objects.requireNonNull(instability, "instability");
|
||||
Objects.requireNonNull(instabilityPlan, "instabilityPlan");
|
||||
Objects.requireNonNull(randomPagePolicy, "randomPagePolicy");
|
||||
randomFeatures = List.copyOf(randomFeatures);
|
||||
if (plan != null && !randomFeatures.isEmpty()) {
|
||||
throw new IllegalArgumentException("Authored Ages cannot also have random features");
|
||||
@@ -88,6 +90,16 @@ public record AgeDefinition(
|
||||
AgeInstabilityPlan.empty());
|
||||
}
|
||||
|
||||
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
|
||||
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
|
||||
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
|
||||
@Nullable AgeSeasonMode seasonMode, List<AgeFeatureSelection.Choice> randomFeatures,
|
||||
AgeInstability instability, AgeInstabilityPlan instabilityPlan) {
|
||||
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
|
||||
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, instability,
|
||||
instabilityPlan, RandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static ResourceLocation dimensionId(UUID ageId) {
|
||||
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + ageId);
|
||||
}
|
||||
@@ -117,7 +129,7 @@ public record AgeDefinition(
|
||||
public AgeDefinition withSpawn(BlockPos spawn) {
|
||||
return new AgeDefinition(id, name, seed, generationProfile, generationVersion,
|
||||
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode,
|
||||
randomFeatures, instability, instabilityPlan);
|
||||
randomFeatures, instability, instabilityPlan, randomPagePolicy);
|
||||
}
|
||||
|
||||
/** A frozen authored profile takes precedence over the profile ID on old books. */
|
||||
|
||||
@@ -16,6 +16,7 @@ import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
||||
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
|
||||
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
|
||||
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
|
||||
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
|
||||
@@ -84,6 +85,7 @@ public final class AgeDimensionService {
|
||||
if (level != null) {
|
||||
applyCelestialClock(level, age);
|
||||
SereneSeasonsCompatibility.registerAge(server, age);
|
||||
RuinsCompatibility.registerAge(age);
|
||||
}
|
||||
}
|
||||
synchronizeImmersivePortals(server);
|
||||
@@ -141,6 +143,7 @@ public final class AgeDimensionService {
|
||||
if (existing != null) {
|
||||
applyCelestialClock(existing, definition);
|
||||
SereneSeasonsCompatibility.registerAge(server, definition);
|
||||
RuinsCompatibility.registerAge(definition);
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -157,6 +160,7 @@ public final class AgeDimensionService {
|
||||
} else {
|
||||
applyCelestialClock(loaded, definition);
|
||||
SereneSeasonsCompatibility.registerAge(server, definition);
|
||||
RuinsCompatibility.registerAge(definition);
|
||||
synchronizeImmersivePortals(server);
|
||||
}
|
||||
return loaded;
|
||||
@@ -461,7 +465,7 @@ public final class AgeDimensionService {
|
||||
AgeBiomeSelection selection = age.plan() != null && age.plan().biomeSelection() != null
|
||||
? age.plan().biomeSelection()
|
||||
: AgeBiomeSelection.compile(net.dnorberg.rivencraft.writing.WritingGrammar.parse(OrderedWriting.EMPTY),
|
||||
age.traits(), profile, age.seed(), server.registryAccess());
|
||||
age.traits(), profile, age.seed(), server.registryAccess(), age.randomPagePolicy());
|
||||
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
|
||||
List<Holder<Biome>> biomes = new ArrayList<>();
|
||||
List<ResourceLocation> fallbacks = AgeBiomeSelection.eligible(server.registryAccess(), profile);
|
||||
|
||||
@@ -155,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))
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
@@ -55,6 +56,11 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
}
|
||||
|
||||
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries, long ageSeed) {
|
||||
return compute(plan, traits, registries, ageSeed, RandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries,
|
||||
long ageSeed, RandomPagePolicy policy) {
|
||||
Objects.requireNonNull(traits, "traits");
|
||||
List<Contribution> contributions = new ArrayList<>();
|
||||
if (plan == null) {
|
||||
@@ -86,7 +92,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
contributions.add(new Contribution("bright_lighting", -1, BRIGHT_LIGHTING_COST,
|
||||
"Seeded bright lighting"));
|
||||
}
|
||||
AgeSkyProfile sky = plan == null && ageSeed != Long.MIN_VALUE ? AgeSkyProfile.random(ageSeed, traits)
|
||||
AgeSkyProfile sky = plan == null && ageSeed != Long.MIN_VALUE ? AgeSkyProfile.random(ageSeed, traits, policy)
|
||||
: plan == null ? new AgeSkyProfile(AgeSkyProfile.Style.INHERITED, -1, List.of())
|
||||
: AgeSkyProfile.compile(plan, traits);
|
||||
int seenSuns = 0;
|
||||
@@ -150,9 +156,12 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
|| !(symbol.semantics().value()
|
||||
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ResourceValue resource)) continue;
|
||||
String name = COUNTED_STRUCTURES.get(resource.id());
|
||||
if (name == null && RuinsTemplatePages.isSymbol(resource.id()))
|
||||
name = RuinsTemplatePages.fallbackName(resource.id());
|
||||
if (name == null) continue;
|
||||
int count = structureCounts.merge(resource.id(), 1, Integer::sum);
|
||||
if (AgeStructureSelection.requiresBiomeOverride(resource.id(), plan.biomeSelection(), registries)) {
|
||||
if (COUNTED_STRUCTURES.containsKey(resource.id())
|
||||
&& AgeStructureSelection.requiresBiomeOverride(resource.id(), plan.biomeSelection(), registries)) {
|
||||
contributions.add(new Contribution("forced_biome_" + resource.id().getPath(), symbol.index(),
|
||||
FORCED_STRUCTURE_BIOME_COST,
|
||||
name + " occurrence " + count + " is forced into an unsupported biome"));
|
||||
@@ -168,7 +177,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
|
||||
static int featureCost(ResourceLocation feature, int occurrence) {
|
||||
if (occurrence < 1) throw new IllegalArgumentException("Feature occurrence must be positive");
|
||||
return COUNTED_STRUCTURES.containsKey(feature) && occurrence > 3
|
||||
return (COUNTED_STRUCTURES.containsKey(feature) || RuinsTemplatePages.isSymbol(feature)) && occurrence > 3
|
||||
? EXCESS_LEGACY_STRUCTURE_COST : 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,13 @@ public final class AgePersistenceGameTests {
|
||||
"Modern repeated structures must use the same deterministic scoring path");
|
||||
helper.assertValueEqual(AgeInstability.featureCost(ResourceLocation.parse("rivencraft:dungeons"), 4), 0,
|
||||
"A Legacy-neutral feature must remain neutral when repeated");
|
||||
ResourceLocation ruin = net.dnorberg.rivencraft.compat.RuinsTemplatePages.symbolId("ZombieHut.tml");
|
||||
helper.assertValueEqual(AgeInstability.featureCost(ruin, 1), 0,
|
||||
"The first Ruins template page is free like a standard structure");
|
||||
helper.assertValueEqual(AgeInstability.featureCost(ruin, 3), 0,
|
||||
"The first three copies of a Ruins page are free");
|
||||
helper.assertValueEqual(AgeInstability.featureCost(ruin, 4), 100,
|
||||
"Each Ruins page beyond the third adds 100 instability");
|
||||
|
||||
OrderedWriting duplicateBright = new OrderedWriting(List.of(
|
||||
WritingEntry.linkPanel(),
|
||||
|
||||
@@ -39,6 +39,7 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
private static final String TRAITS_TAG = "Traits";
|
||||
private static final String PLAN_TAG = "GenerationPlan";
|
||||
private static final String RANDOM_FEATURES_TAG = "RandomFeatures";
|
||||
private static final String RANDOM_PAGE_POLICY_TAG = "RandomPagePolicy";
|
||||
private static final String SEASON_MODE_TAG = "SeasonMode";
|
||||
private static final String INSTABILITY_TAG = "Instability";
|
||||
private static final String INSTABILITY_PLAN_TAG = "InstabilityPlan";
|
||||
@@ -77,9 +78,19 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
ServerLevel overworld = level.getServer().overworld();
|
||||
AgeRegistrySavedData data = get(overworld);
|
||||
long seed = ageSeed(overworld.getSeed(), data.availableDimensionId(normalizedName(name)));
|
||||
AgeGenerationPlan.compile(writing, seed,
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed,
|
||||
AgeRuntimeProfiles.selectForWriting(seed, writing, overworld.registryAccess()), AgeTraits.select(seed),
|
||||
overworld.registryAccess());
|
||||
if (plan == null) {
|
||||
RandomPagePolicy policy = RandomPagePolicy.configured();
|
||||
ResourceLocation profileId = AgeRuntimeProfiles.select(seed, policy);
|
||||
AgeRuntimeProfile profile = AgeRuntimeProfiles.resolve(profileId);
|
||||
AgeTraits traits = AgeTraits.select(seed, policy);
|
||||
AgeBiomeSelection.compile(net.dnorberg.rivencraft.writing.WritingGrammar.parse(OrderedWriting.EMPTY),
|
||||
traits, profile, seed, overworld.registryAccess(), policy);
|
||||
AgeSkyProfile.random(seed, traits, policy);
|
||||
AgeSeasonMode.select(null, seed, ModList.get().isLoaded("sereneseasons"), profile, policy);
|
||||
}
|
||||
}
|
||||
|
||||
public static ResourceLocation previewGenerationProfile(ServerLevel level, String name) {
|
||||
@@ -155,7 +166,8 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
AgeDefinition changed = new AgeDefinition(previous.id(), previous.name(), seed,
|
||||
previous.generationProfile(), previous.generationVersion(), previous.createdGameTime(),
|
||||
previous.dimensionId(), previous.spawn(), previous.traits(), plan, previous.seasonMode(),
|
||||
previous.randomFeatures(), previous.instability(), previous.instabilityPlan());
|
||||
previous.randomFeatures(), previous.instability(), previous.instabilityPlan(),
|
||||
previous.randomPagePolicy());
|
||||
if (AgeDimensionService.unload(server, previous) == AgeDimensionService.UnloadResult.FAILED)
|
||||
return false;
|
||||
ages.put(ageId, changed);
|
||||
@@ -173,13 +185,19 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
String name = normalizedName(reference.name());
|
||||
ResourceLocation dimensionId = availableDimensionId(name);
|
||||
long seed = ageSeed(worldSeed, dimensionId);
|
||||
RandomPagePolicy policy = RandomPagePolicy.configured();
|
||||
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
|
||||
AgeTraits traits = AgeTraits.select(seed);
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
|
||||
if (plan == null) {
|
||||
seedProfile = AgeRuntimeProfiles.select(seed, policy);
|
||||
traits = AgeTraits.select(seed, policy);
|
||||
}
|
||||
AgeTraits effectiveTraits = plan == null
|
||||
? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile))
|
||||
: AgeEnvironmentCompiler.apply(plan, traits);
|
||||
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries, seed);
|
||||
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries, seed,
|
||||
plan == null ? policy : RandomPagePolicy.UNRESTRICTED);
|
||||
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
|
||||
AgeDefinition created = new AgeDefinition(
|
||||
reference.id(),
|
||||
@@ -193,11 +211,13 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
effectiveTraits,
|
||||
plan,
|
||||
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"),
|
||||
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile()),
|
||||
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile(),
|
||||
plan == null ? policy : RandomPagePolicy.UNRESTRICTED),
|
||||
plan == null ? AgeFeatureSelection.randomChoices(seed,
|
||||
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
|
||||
AgeRuntimeProfiles.resolve(seedProfile), registries, policy) : List.of(),
|
||||
instability,
|
||||
instabilityPlan
|
||||
instabilityPlan,
|
||||
plan == null ? policy : RandomPagePolicy.UNRESTRICTED
|
||||
);
|
||||
ages.put(created.id(), created);
|
||||
setDirty();
|
||||
@@ -264,7 +284,10 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null,
|
||||
readRandomFeatures(entry),
|
||||
instability,
|
||||
instabilityPlan
|
||||
instabilityPlan,
|
||||
entry.contains(RANDOM_PAGE_POLICY_TAG, CompoundTag.TAG_COMPOUND)
|
||||
? RandomPagePolicy.load(entry.getCompound(RANDOM_PAGE_POLICY_TAG))
|
||||
: RandomPagePolicy.UNRESTRICTED
|
||||
));
|
||||
}
|
||||
return data;
|
||||
@@ -309,6 +332,7 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
entry.put(RANDOM_FEATURES_TAG, featureTags);
|
||||
}
|
||||
if (age.seasonMode() != null) entry.putString(SEASON_MODE_TAG, age.seasonMode().name());
|
||||
if (age.plan() == null) entry.put(RANDOM_PAGE_POLICY_TAG, age.randomPagePolicy().save());
|
||||
entry.put(INSTABILITY_TAG, age.instability().save());
|
||||
entry.put(INSTABILITY_PLAN_TAG, age.instabilityPlan().save());
|
||||
serializedAges.add(entry);
|
||||
|
||||
@@ -57,20 +57,39 @@ public final class AgeRuntimeProfiles {
|
||||
|
||||
/** Selects from all valid loaded profiles in stable resource-ID order. */
|
||||
public static ResourceLocation select(long seed) {
|
||||
return select(seed, RandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static ResourceLocation select(long seed, RandomPagePolicy policy) {
|
||||
if (PROFILES.isEmpty()) {
|
||||
return AgeGenerationProfiles.select(seed);
|
||||
List<LegacyRankChoice.Option<ResourceLocation>> profiles = policy.required(
|
||||
AgeGenerationProfiles.all().stream().sorted()
|
||||
.map(id -> new LegacyRankChoice.Option<>(id, AgeGenerationProfiles.randomRank(id)))
|
||||
.toList(), option -> terrainPage(option.value()),
|
||||
new LegacyRankChoice.Option<>(AgeGenerationProfiles.STANDARD, 1));
|
||||
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), profiles);
|
||||
}
|
||||
List<LegacyRankChoice.Option<ResourceLocation>> profiles = PROFILES.keySet().stream().sorted()
|
||||
List<LegacyRankChoice.Option<ResourceLocation>> available = PROFILES.keySet().stream().sorted()
|
||||
.map(id -> new LegacyRankChoice.Option<>(id, AgeGenerationProfiles.randomRank(id))).toList();
|
||||
LegacyRankChoice.Option<ResourceLocation> fallback = available.stream()
|
||||
.filter(option -> option.value().equals(AgeGenerationProfiles.STANDARD))
|
||||
.findFirst().orElse(available.getFirst());
|
||||
List<LegacyRankChoice.Option<ResourceLocation>> profiles = policy.required(available,
|
||||
option -> terrainPage(option.value()), fallback);
|
||||
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), profiles);
|
||||
}
|
||||
|
||||
private static ResourceLocation terrainPage(ResourceLocation profile) {
|
||||
return ResourceLocation.fromNamespaceAndPath("rivencraft", "terrain/" + profile.getPath());
|
||||
}
|
||||
|
||||
/** Keep seed-selected terrain random, but only among profiles able to host authored features. */
|
||||
public static ResourceLocation selectForWriting(long seed, OrderedWriting writing, RegistryAccess registries) {
|
||||
ResourceLocation selected = select(seed);
|
||||
WritingGrammar.Result parsed = WritingGrammar.parse(writing);
|
||||
if (parsed.activeSymbols().stream().anyMatch(symbol ->
|
||||
symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)) return selected;
|
||||
boolean authoredTerrain = parsed.activeSymbols().stream().anyMatch(symbol ->
|
||||
symbol.semantics().role() == SymbolSemantics.Role.TERRAIN);
|
||||
ResourceLocation selected = select(seed);
|
||||
if (authoredTerrain) return selected;
|
||||
List<ResourceLocation> features = parsed.activeSymbols().stream()
|
||||
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
|
||||
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList();
|
||||
|
||||
@@ -24,15 +24,23 @@ public enum AgeSeasonMode {
|
||||
|
||||
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
|
||||
AgeRuntimeProfile profile) {
|
||||
return select(plan, ageSeed, sereneInstalled, profile, RandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
|
||||
AgeRuntimeProfile profile, RandomPagePolicy policy) {
|
||||
if (profile != null && AgeBiomeSelection.isCave(profile)) return NO_SEASONS;
|
||||
if (hasAuthoredPage(plan)) return from(plan);
|
||||
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
|
||||
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), policy.required(List.of(
|
||||
new LegacyRankChoice.Option<>(SEASONS, 1),
|
||||
new LegacyRankChoice.Option<>(NO_SEASONS, 2),
|
||||
new LegacyRankChoice.Option<>(ETERNAL_WINTER, 3),
|
||||
new LegacyRankChoice.Option<>(ETERNAL_SUMMER, 3),
|
||||
new LegacyRankChoice.Option<>(ETERNAL_FALL, 3),
|
||||
new LegacyRankChoice.Option<>(ETERNAL_SPRING, 3))) : null;
|
||||
new LegacyRankChoice.Option<>(ETERNAL_SPRING, 3)),
|
||||
option -> net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("rivencraft",
|
||||
"season/" + option.value().name().toLowerCase(Locale.ROOT)),
|
||||
new LegacyRankChoice.Option<>(NO_SEASONS, 2))) : null;
|
||||
}
|
||||
|
||||
/** Existing Ages without a stored choice retain their old cycling behavior. */
|
||||
|
||||
@@ -6,6 +6,7 @@ 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) {
|
||||
@@ -23,16 +24,25 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
|
||||
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());
|
||||
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);
|
||||
Style style = Style.values()[random.nextInt(Style.values().length)];
|
||||
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 = random.nextInt(4);
|
||||
int moons = random.nextInt(4);
|
||||
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),
|
||||
@@ -45,6 +55,11 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
|
||||
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;
|
||||
@@ -59,13 +74,13 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
|
||||
float size = 1.0F;
|
||||
List<Integer> bodyColors = new ArrayList<>();
|
||||
ColorAccumulator pendingColors = new ColorAccumulator();
|
||||
Integer colorMultiplier = null;
|
||||
Double pendingNumber = null;
|
||||
boolean clockwise = true;
|
||||
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) colorMultiplier = null;
|
||||
Double number = pendingNumber;
|
||||
if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null;
|
||||
switch (semantics.role()) {
|
||||
case SKY -> {
|
||||
pendingColors.clear();
|
||||
@@ -77,17 +92,16 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
|
||||
default -> throw new IllegalArgumentException("Unknown sky style");
|
||||
};
|
||||
}
|
||||
case COLOR_NUMBER -> colorMultiplier = (int) ((SymbolSemantics.NumberValue)
|
||||
case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue)
|
||||
semantics.value()).amount();
|
||||
case COLOR -> {
|
||||
pendingColors.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(),
|
||||
colorMultiplier == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : colorMultiplier);
|
||||
colorMultiplier = null;
|
||||
number == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : number.intValue());
|
||||
}
|
||||
case PERIOD_MODIFIER -> period = ((SymbolSemantics.NumberValue) semantics.value()).amount();
|
||||
case ANGLE_MODIFIER -> angle = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
|
||||
case PHASE_MODIFIER -> phase = (float) (((SymbolSemantics.NumberValue) semantics.value()).amount() / 360.0D);
|
||||
case SIZE_MODIFIER -> size = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
|
||||
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())
|
||||
@@ -95,7 +109,7 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
|
||||
case CELESTIAL -> {
|
||||
String choice = ((SymbolSemantics.ChoiceValue) semantics.value()).id();
|
||||
int bodyColor = pendingColors.written() ? pendingColors.rgb()
|
||||
: bodyColors.isEmpty() ? 0 : mixColors(bodyColors);
|
||||
: bodyColors.isEmpty() ? 0xFFFFFF : mixColors(bodyColors);
|
||||
if (choice.equals("normal_time")) {
|
||||
if (customBodies) {
|
||||
bodies.add(new Body(Kind.SUN, bodyColor, size, angle, period, phase, clockwise));
|
||||
@@ -221,6 +235,7 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
|
||||
&& !((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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,57 @@ public final class EnvironmentWritingGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void numberPagesSetAdjacentCelestialModifiers(GameTestHelper helper) {
|
||||
OrderedWriting writing = new OrderedWriting(List.of(
|
||||
WritingEntry.linkPanel(), page("terrain/standard"), page("sky/overworld"),
|
||||
page("number/1"), page("period/double"),
|
||||
page("number/7"), page("angle/east"),
|
||||
page("number/12"), page("phase/rising"), page("celestial/sun"),
|
||||
page("number/24"), page("angle/west"),
|
||||
page("number/0"), page("phase/setting"),
|
||||
page("number/0"), page("period/full"), page("celestial/moon")));
|
||||
helper.assertValueEqual(WritingGrammar.parse(writing).writingInstability(), 0,
|
||||
"Adjacent number and celestial modifier pages must not count as misplaced writing");
|
||||
AgeTraits seeded = AgeTraits.select(94L);
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 94L,
|
||||
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
|
||||
AgeTraits effective = AgeEnvironmentCompiler.apply(plan, seeded);
|
||||
AgeSkyProfile sky = AgeSkyProfile.compile(plan, effective);
|
||||
helper.assertValueEqual(sky.bodies().getFirst().period(), 0.08D,
|
||||
"A number overrides a fixed Length page at 0.08 per digit, even below the random minimum");
|
||||
helper.assertValueEqual(sky.bodies().getFirst().angle(), 105.0F,
|
||||
"A number overrides a fixed Angle page at 15 degrees per digit");
|
||||
helper.assertValueEqual(sky.bodies().getFirst().phase(), 0.5F,
|
||||
"A number overrides a fixed Phase page at 15 degrees per digit");
|
||||
helper.assertValueEqual(sky.bodies().getLast().angle(), 0.0F,
|
||||
"Digit 24 wraps a full 360-degree rotation to zero");
|
||||
helper.assertValueEqual(sky.bodies().getLast().phase(), 0.0F,
|
||||
"Digit zero is a valid phase override");
|
||||
helper.assertValueEqual(effective.celestialPeriod(), 0.0D,
|
||||
"The final authored celestial controls the world clock");
|
||||
helper.assertValueEqual(AgeSkyProfile.compile(AgeGenerationPlan.load(plan.save()), effective), sky,
|
||||
"Numbered celestial values must survive a plan save/load round trip");
|
||||
AgeGenerationPlan normalTime = AgeGenerationPlan.compile(new OrderedWriting(List.of(
|
||||
WritingEntry.linkPanel(), page("number/6"), page("angle/north"),
|
||||
page("celestial/normal_time"))), 95L, AgeGenerationProfiles.STANDARD,
|
||||
AgeTraits.select(95L), helper.getLevel().registryAccess());
|
||||
helper.assertValueEqual(AgeEnvironmentCompiler.skyEffects(normalTime,
|
||||
BuiltinDimensionTypes.OVERWORLD_EFFECTS), AgeEnvironmentCompiler.CUSTOM_SKY_EFFECTS,
|
||||
"An authored angle on Normal Time must select the custom celestial renderer");
|
||||
AgeSkyProfile normalSky = AgeSkyProfile.compile(normalTime,
|
||||
AgeEnvironmentCompiler.apply(normalTime, AgeTraits.select(95L)));
|
||||
helper.assertValueEqual(normalSky.bodies().getFirst().angle(), 90.0F,
|
||||
"The custom Normal Time sun must honor its numbered angle");
|
||||
helper.assertValueEqual(normalSky.bodies().getFirst().color(), 0xFFFFFF,
|
||||
"Uncolored custom celestial bodies must remain visible");
|
||||
OrderedWriting separated = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||
page("number/7"), page("direction/clockwise"), page("angle/east"), page("celestial/sun")));
|
||||
helper.assertValueEqual(WritingGrammar.parse(separated).writingInstability(), 100,
|
||||
"A number does not reach past an intervening page");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void alignedMoonEclipsesSunAndDimsLight(GameTestHelper helper) {
|
||||
AgeSkyProfile.Body sun = new AgeSkyProfile.Body(AgeSkyProfile.Kind.SUN,
|
||||
|
||||
@@ -43,8 +43,9 @@ public final class HandheldBookTravelGameTests {
|
||||
helper.assertTrue(player.getInventory().getItem(1).is(Items.DIAMOND),
|
||||
"An unrelated inventory item must remain with the player");
|
||||
var drops = helper.getLevel().getEntitiesOfClass(DroppedBookEntity.class, new AABB(source, source).inflate(2.0),
|
||||
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get()));
|
||||
helper.assertValueEqual(drops.size(), 1, "Exactly one recoverable book must remain at the departure point");
|
||||
entity -> entity.getItem() == book);
|
||||
helper.assertValueEqual(drops.size(), 1,
|
||||
"Exactly one entity for this active book must remain at the departure point");
|
||||
helper.assertTrue(drops.getFirst().getItem() == book,
|
||||
"The model-rendered entity must retain the exact active book stack");
|
||||
helper.assertTrue(drops.getFirst().copy() instanceof DroppedBookEntity,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.StringTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** A snapshot of Page eligibility, frozen with each random Age at binding time. */
|
||||
public record RandomPagePolicy(Set<ResourceLocation> allowlist, Set<ResourceLocation> blocklist) {
|
||||
public static final RandomPagePolicy UNRESTRICTED = new RandomPagePolicy(Set.of(), Set.of());
|
||||
|
||||
public RandomPagePolicy {
|
||||
allowlist = Set.copyOf(allowlist);
|
||||
blocklist = Set.copyOf(blocklist);
|
||||
}
|
||||
|
||||
public static RandomPagePolicy configured() {
|
||||
return new RandomPagePolicy(parse(RivencraftConfig.randomPageAllowlist()),
|
||||
parse(RivencraftConfig.randomPageBlocklist()));
|
||||
}
|
||||
|
||||
private static Set<ResourceLocation> parse(List<? extends String> ids) {
|
||||
return ids.stream().map(RandomPagePolicy::parsePageId).collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
/** Server config IDs may omit the built-in Rivencraft namespace. */
|
||||
public static ResourceLocation parsePageId(String value) {
|
||||
ResourceLocation id = ResourceLocation.tryParse(value.contains(":") ? value : "rivencraft:" + value);
|
||||
if (id == null) throw new IllegalArgumentException("Invalid Page ID " + value);
|
||||
return id;
|
||||
}
|
||||
|
||||
public boolean allows(ResourceLocation id) {
|
||||
if (blocklist.contains(id)) return false;
|
||||
String category = category(id);
|
||||
return !restricts(category) || allowlist.contains(id);
|
||||
}
|
||||
|
||||
public boolean restricts(String category) {
|
||||
return allowlist.stream().anyMatch(entry -> category(entry).equals(category));
|
||||
}
|
||||
|
||||
public <T> List<T> eligible(List<T> candidates, java.util.function.Function<T, ResourceLocation> pageId) {
|
||||
return candidates.stream().filter(candidate -> allows(pageId.apply(candidate))).toList();
|
||||
}
|
||||
|
||||
public <T> List<T> required(List<T> candidates, java.util.function.Function<T, ResourceLocation> pageId,
|
||||
T fallback) {
|
||||
List<T> eligible = eligible(candidates, pageId);
|
||||
return eligible.isEmpty() ? List.of(fallback) : eligible;
|
||||
}
|
||||
|
||||
private static String category(ResourceLocation id) {
|
||||
String path = id.getPath();
|
||||
if (path.startsWith("biome/") && java.util.Set.of("native", "single", "tiled", "grid",
|
||||
"tiny", "small", "medium", "large", "huge").contains(path.substring("biome/".length())))
|
||||
return "biome_distribution";
|
||||
return path.substring(0, path.indexOf('/') < 0 ? path.length() : path.indexOf('/'));
|
||||
}
|
||||
|
||||
public CompoundTag save() {
|
||||
CompoundTag tag = new CompoundTag();
|
||||
tag.put("Allowlist", strings(allowlist));
|
||||
tag.put("Blocklist", strings(blocklist));
|
||||
return tag;
|
||||
}
|
||||
|
||||
public static RandomPagePolicy load(CompoundTag tag) {
|
||||
return new RandomPagePolicy(read(tag.getList("Allowlist", StringTag.TAG_STRING)),
|
||||
read(tag.getList("Blocklist", StringTag.TAG_STRING)));
|
||||
}
|
||||
|
||||
private static ListTag strings(Set<ResourceLocation> ids) {
|
||||
ListTag tags = new ListTag();
|
||||
ids.stream().sorted().forEach(id -> tags.add(StringTag.valueOf(id.toString())));
|
||||
return tags;
|
||||
}
|
||||
|
||||
private static Set<ResourceLocation> read(ListTag tags) {
|
||||
java.util.Set<ResourceLocation> ids = new java.util.HashSet<>();
|
||||
for (int index = 0; index < tags.size(); index++) {
|
||||
ResourceLocation id = ResourceLocation.tryParse(tags.getString(index));
|
||||
if (id != null) ids.add(id);
|
||||
}
|
||||
return Set.copyOf(ids);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.mojang.math.Axis;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.client.model.BookModel;
|
||||
import net.minecraft.client.model.geom.ModelLayers;
|
||||
@@ -30,11 +31,15 @@ public final class BookReceptacleRenderer implements BlockEntityRenderer<BookRec
|
||||
@Override
|
||||
public void render(BookReceptacleBlockEntity receptacle, float partialTick, PoseStack poseStack,
|
||||
MultiBufferSource buffers, int packedLight, int packedOverlay) {
|
||||
if (receptacle.getBook().isEmpty()) return;
|
||||
if (receptacle.getLevel() == null) return;
|
||||
var state = receptacle.getLevel().getBlockState(receptacle.getBlockPos());
|
||||
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get())
|
||||
|| !state.getValue(BookReceptacleBlock.HAS_BOOK)
|
||||
|| receptacle.getBook().isEmpty()) return;
|
||||
|
||||
poseStack.pushPose();
|
||||
poseStack.translate(0.5, 0.5, 0.5);
|
||||
Direction facing = receptacle.getBlockState().getValue(BookReceptacleBlock.FACING);
|
||||
Direction facing = state.getValue(BookReceptacleBlock.FACING);
|
||||
switch (facing) {
|
||||
case UP -> {
|
||||
// Rotate the whole assembled top-mounted receptacle/book pair
|
||||
|
||||
@@ -107,7 +107,7 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
}
|
||||
graphics.disableScissor();
|
||||
drawScrollEnds(graphics);
|
||||
var severity = menu.grammarSeverity().orElse(null);
|
||||
var severity = menu.diagnosticsVisible() ? menu.grammarSeverity().orElse(null) : null;
|
||||
Component status = severity == WritingGrammar.Severity.FATAL
|
||||
? Component.translatable("screen.rivencraft.book_binder.status.fatal")
|
||||
: severity == WritingGrammar.Severity.INSTABILITY
|
||||
@@ -117,9 +117,9 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
if (status != null) graphics.drawString(font, status, leftPos + 77, topPos + 30,
|
||||
severity == WritingGrammar.Severity.FATAL ? 0xFFAA3030
|
||||
: severity == WritingGrammar.Severity.INSTABILITY ? 0xFF924F18 : 0xFF50624B, false);
|
||||
Component grammarTooltip = menu.grammarError().map(code -> Component.translatable(
|
||||
Component grammarTooltip = menu.diagnosticsVisible() ? menu.grammarError().map(code -> Component.translatable(
|
||||
"screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT),
|
||||
errorStripIndex + 1)).orElse(null);
|
||||
errorStripIndex + 1)).orElse(null) : null;
|
||||
if (grammarTooltip != null && ((mouseX >= leftPos + 77 && mouseX < leftPos + 151)
|
||||
|| (mouseX >= leftPos + 152 && mouseX < leftPos + 168))
|
||||
&& mouseY >= topPos + 27 && mouseY < topPos + 43
|
||||
|
||||
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.dnorberg.rivencraft.book.AgeInstabilityControls;
|
||||
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.dnorberg.rivencraft.menu.BookBinderDiagnostics;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
@@ -29,6 +30,13 @@ public final class RivencraftCommands {
|
||||
public static void register(RegisterCommandsEvent event) {
|
||||
event.getDispatcher().register(Commands.literal("rivencraft")
|
||||
.requires(source -> source.hasPermission(2))
|
||||
.then(Commands.literal("binder")
|
||||
.then(Commands.literal("messages")
|
||||
.executes(context -> binderMessages(context.getSource(), null))
|
||||
.then(Commands.literal("enable")
|
||||
.executes(context -> binderMessages(context.getSource(), true)))
|
||||
.then(Commands.literal("disable")
|
||||
.executes(context -> binderMessages(context.getSource(), false)))))
|
||||
.then(Commands.literal("library")
|
||||
.then(Commands.literal("nearest")
|
||||
.executes(context -> LibraryLocateService.start(context.getSource())))
|
||||
@@ -94,6 +102,14 @@ public final class RivencraftCommands {
|
||||
.then(environmentCommand())));
|
||||
}
|
||||
|
||||
private static int binderMessages(CommandSourceStack source, Boolean enabled) {
|
||||
BookBinderDiagnostics diagnostics = BookBinderDiagnostics.get(source.getLevel());
|
||||
if (enabled != null) diagnostics.setEnabled(enabled);
|
||||
source.sendSuccess(() -> Component.literal("Book Binder diagnostic messages are "
|
||||
+ (diagnostics.enabled() ? "enabled for operators" : "disabled") + "."), false);
|
||||
return diagnostics.enabled() ? 1 : 0;
|
||||
}
|
||||
|
||||
private static LiteralArgumentBuilder<CommandSourceStack> environmentCommand() {
|
||||
return Commands.literal("environment")
|
||||
.then(Commands.literal("pulse")
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.dnorberg.rivencraft.book.AgeDefinition;
|
||||
import net.dnorberg.rivencraft.book.AgeFeatureSelection;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.neoforged.fml.ModList;
|
||||
|
||||
/** Small optional bridge between frozen Age pages and Ruins' dimension gate. */
|
||||
public final class RuinsCompatibility {
|
||||
private static final Set<ResourceLocation> KNOWN_AGES = ConcurrentHashMap.newKeySet();
|
||||
private static final Map<ResourceLocation, Map<ResourceLocation, Integer>> AUTHORED_TEMPLATES =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private RuinsCompatibility() {}
|
||||
|
||||
public static void register() {
|
||||
if (ModList.get().isLoaded("ruins")) RuinsEventAdapter.register();
|
||||
}
|
||||
|
||||
public static void registerAge(AgeDefinition age) {
|
||||
if (!ModList.get().isLoaded("ruins")) return;
|
||||
KNOWN_AGES.add(age.dimensionId());
|
||||
List<AgeFeatureSelection.Choice> choices = age.plan() == null
|
||||
? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan());
|
||||
Map<ResourceLocation, Integer> selected = templateCounts(choices);
|
||||
if (!selected.isEmpty()) {
|
||||
AUTHORED_TEMPLATES.put(age.dimensionId(), selected);
|
||||
} else {
|
||||
AUTHORED_TEMPLATES.remove(age.dimensionId());
|
||||
}
|
||||
}
|
||||
|
||||
/** Called only by the optional Ruins FileHandler mixin. */
|
||||
public static boolean allowsAge(ResourceLocation dimension) {
|
||||
return AUTHORED_TEMPLATES.containsKey(dimension);
|
||||
}
|
||||
|
||||
public static boolean isKnownAge(ResourceLocation dimension) {
|
||||
return KNOWN_AGES.contains(dimension);
|
||||
}
|
||||
|
||||
public static boolean permits(ResourceLocation dimension, ResourceLocation template) {
|
||||
return templateCount(dimension, template) > 0;
|
||||
}
|
||||
|
||||
public static int templateCount(ResourceLocation dimension, ResourceLocation template) {
|
||||
return AUTHORED_TEMPLATES.getOrDefault(dimension, Map.of()).getOrDefault(template, 0);
|
||||
}
|
||||
|
||||
/** Extra pages increase attempts, without making distinct template choices denser. */
|
||||
public static int attemptsPerChunk(ResourceLocation dimension, int configuredAttempts) {
|
||||
return attemptsPerChunk(configuredAttempts, AUTHORED_TEMPLATES.get(dimension));
|
||||
}
|
||||
|
||||
static int attemptsPerChunk(int configuredAttempts, Map<ResourceLocation, Integer> counts) {
|
||||
if (counts == null || counts.isEmpty()) return configuredAttempts;
|
||||
int duplicates = counts.values().stream().mapToInt(count -> count - 1).sum();
|
||||
return (int) Math.min(Math.max(configuredAttempts, 32L), (long) configuredAttempts + duplicates);
|
||||
}
|
||||
|
||||
static Map<ResourceLocation, Integer> templateCounts(List<AgeFeatureSelection.Choice> choices) {
|
||||
Map<ResourceLocation, Integer> counts = new java.util.HashMap<>();
|
||||
for (AgeFeatureSelection.Choice choice : choices) {
|
||||
if (RuinsTemplatePages.contains(choice.id())) counts.merge(choice.id(), 1, Integer::sum);
|
||||
}
|
||||
return Map.copyOf(counts);
|
||||
}
|
||||
|
||||
public static void onServerStopping() {
|
||||
KNOWN_AGES.clear();
|
||||
AUTHORED_TEMPLATES.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.book.AgeFeatureSelection;
|
||||
import net.dnorberg.rivencraft.book.AgeReference;
|
||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.nio.file.Files;
|
||||
import net.neoforged.fml.loading.FMLPaths;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Each discoverable Ruins template must have an authorable page, or none do. */
|
||||
@GameTestHolder("rivencraft")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class RuinsCompatibilityGameTests {
|
||||
private RuinsCompatibilityGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void ruinsPagesMatchInstalledTemplates(GameTestHelper helper) {
|
||||
var templates = RuinsTemplatePages.templates();
|
||||
if (!ModList.get().isLoaded("ruins")) {
|
||||
helper.assertTrue(templates.isEmpty(), "Ruins pages must be absent without the optional mod");
|
||||
} else {
|
||||
var creative = PageItem.creativeSymbolPages(helper.getLevel().registryAccess(),
|
||||
RuinsTemplatePages.CATEGORY);
|
||||
helper.assertValueEqual(creative.size(), templates.size(),
|
||||
"Creative and JEI must expose exactly one page per Ruins template");
|
||||
for (String name : templates) {
|
||||
var id = RuinsTemplatePages.symbolId(name);
|
||||
helper.assertTrue(SymbolCatalog.isKnown(new SymbolReference(id)),
|
||||
"Ruins template " + name + " must be authorable");
|
||||
helper.assertTrue(PageItem.isUsableWriting(PageItem.createCreativeRuinsPage(name)),
|
||||
"Ruins template " + name + " needs a usable page");
|
||||
}
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void duplicatePagesRetainTheirCount(GameTestHelper helper) {
|
||||
if (RuinsTemplatePages.templates().isEmpty()) {
|
||||
helper.succeed();
|
||||
return;
|
||||
}
|
||||
var template = RuinsTemplatePages.symbolId(RuinsTemplatePages.templates().getFirst());
|
||||
var counts = RuinsCompatibility.templateCounts(List.of(
|
||||
new AgeFeatureSelection.Choice(template, Optional.empty()),
|
||||
new AgeFeatureSelection.Choice(template, Optional.empty()),
|
||||
new AgeFeatureSelection.Choice(template, Optional.empty())));
|
||||
helper.assertValueEqual(counts.get(template), 3,
|
||||
"Three authored Ruins pages must triple that template's relative selection weight");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void duplicatePagesIncreaseAttemptsWithinBudget(GameTestHelper helper) {
|
||||
var first = RuinsTemplatePages.symbolId("First.tml");
|
||||
var second = RuinsTemplatePages.symbolId("Second.tml");
|
||||
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(6, Map.of(first, 1)), 6,
|
||||
"One Ruins page must retain the configured attempt count");
|
||||
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(6, Map.of(first, 3, second, 1)), 8,
|
||||
"Only duplicate pages must add one attempt each");
|
||||
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(6, Map.of(first, 50)), 32,
|
||||
"Duplicate pages must not exceed the per-chunk attempt budget");
|
||||
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(40, Map.of(first, 2)), 40,
|
||||
"The budget must not lower an intentionally higher Ruins configuration");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void authoredTemplateBypassesOverworldOnlyGate(GameTestHelper helper) throws Exception {
|
||||
if (!ModList.get().isLoaded("ruins")) {
|
||||
helper.succeed();
|
||||
return;
|
||||
}
|
||||
String name = "tikihead1.tml";
|
||||
var path = FMLPaths.GAMEDIR.get().resolve("config/ruins_config/generic").resolve(name);
|
||||
if (!Files.exists(path) || !RuinsTemplatePages.contains(RuinsTemplatePages.symbolId(name))) {
|
||||
helper.succeed(); // Custom Ruins packs need not include the bundled Tiki Head.
|
||||
return;
|
||||
}
|
||||
var writing = new OrderedWriting(List.of(
|
||||
WritingEntry.linkPanel(),
|
||||
WritingEntry.symbol(new SymbolReference(net.minecraft.resources.ResourceLocation.parse(
|
||||
"rivencraft:terrain/standard"))),
|
||||
WritingEntry.symbol(new SymbolReference(net.minecraft.resources.ResourceLocation.parse(
|
||||
"rivencraft:biome/plains"))),
|
||||
WritingEntry.symbol(new SymbolReference(net.minecraft.resources.ResourceLocation.parse(
|
||||
"rivencraft:biome/single"))),
|
||||
WritingEntry.symbol(new SymbolReference(RuinsTemplatePages.symbolId(name)))));
|
||||
var age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(UUID.randomUUID(), "Ruins Tiki Gate"), writing);
|
||||
RuinsCompatibility.registerAge(age);
|
||||
var ruinsClass = Class.forName("atomicstryker.ruins.common.RuinsMod");
|
||||
Object ruins = ruinsClass.getMethod("getInstance").invoke(null);
|
||||
var currentLevel = ruinsClass.getDeclaredField("lastLoadedLevel");
|
||||
currentLevel.setAccessible(true);
|
||||
Object previousLevel = currentLevel.get(ruins);
|
||||
var templateClass = Class.forName("atomicstryker.ruins.common.RuinTemplate");
|
||||
try {
|
||||
currentLevel.set(ruins, helper.getLevel());
|
||||
Object template = templateClass.getConstructor(String.class, String.class)
|
||||
.newInstance(path.toString(), name);
|
||||
boolean accepted = (boolean) templateClass.getMethod("acceptsDimension", String.class)
|
||||
.invoke(template, age.dimensionId().getPath());
|
||||
helper.assertTrue(accepted, "The authored Tiki Head must load despite dimensionsToSpawnIn=overworld");
|
||||
} finally {
|
||||
currentLevel.set(ruins, previousLevel);
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import atomicstryker.ruins.common.EventRuinTemplateSpawn;
|
||||
import atomicstryker.ruins.common.RuinTemplate;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
|
||||
/** Loaded only when Ruins is present. Its pre-spawn event can veto exact templates. */
|
||||
public final class RuinsEventAdapter {
|
||||
private RuinsEventAdapter() {}
|
||||
|
||||
static void register() {
|
||||
NeoForge.EVENT_BUS.addListener(RuinsEventAdapter::onTemplateSpawn);
|
||||
}
|
||||
|
||||
private static void onTemplateSpawn(EventRuinTemplateSpawn event) {
|
||||
if (!event.isPrePhase || event.testingRuin
|
||||
|| !(event.getLevel() instanceof ServerLevel level)
|
||||
|| !RuinsCompatibility.isKnownAge(level.dimension().location())) return;
|
||||
|
||||
var template = RuinsTemplatePages.featureId(event.template.getName());
|
||||
if (!RuinsTemplatePages.contains(template)
|
||||
|| !RuinsCompatibility.permits(level.dimension().location(), template)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
/** Preserve Ruins' weights and multiply each by its authored page count. */
|
||||
public static Object chooseTemplate(net.minecraft.resources.ResourceLocation dimension,
|
||||
Collection<?> pool, RandomSource random) {
|
||||
if (pool == null || pool.isEmpty()) return null;
|
||||
var candidates = pool.stream().filter(RuinTemplate.class::isInstance)
|
||||
.map(RuinTemplate.class::cast)
|
||||
.filter(template -> RuinsCompatibility.permits(dimension,
|
||||
RuinsTemplatePages.featureId(template.getName())))
|
||||
.sorted(Comparator.comparing(RuinTemplate::getName)).toList();
|
||||
double total = candidates.stream().mapToDouble(template -> authoredWeight(dimension, template)).sum();
|
||||
if (total <= 0.0) return null;
|
||||
double roll = random.nextDouble() * total;
|
||||
for (RuinTemplate candidate : candidates) {
|
||||
roll -= authoredWeight(dimension, candidate);
|
||||
if (roll < 0.0) return candidate;
|
||||
}
|
||||
return candidates.getLast();
|
||||
}
|
||||
|
||||
private static double authoredWeight(net.minecraft.resources.ResourceLocation dimension,
|
||||
RuinTemplate template) {
|
||||
return Math.max(0.0, template.getWeight()) * RuinsCompatibility.templateCount(dimension,
|
||||
RuinsTemplatePages.featureId(template.getName()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.SymbolDefinition;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.fml.loading.FMLPaths;
|
||||
|
||||
/** Discovers Ruins' template filenames without making its jar mandatory. */
|
||||
public final class RuinsTemplatePages {
|
||||
public static final ResourceLocation CATEGORY = ResourceLocation.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, "ruins");
|
||||
private static final String PREFIX = "ruins/";
|
||||
private static volatile Map<ResourceLocation, String> names = Map.of();
|
||||
private static volatile boolean initialized;
|
||||
|
||||
private RuinsTemplatePages() {}
|
||||
|
||||
public static void refresh() {
|
||||
if (!ModList.get().isLoaded("ruins")) {
|
||||
names = Map.of();
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
Map<ResourceLocation, String> discovered = new HashMap<>();
|
||||
Map<String, String> sources = new HashMap<>();
|
||||
Set<String> duplicateNames = new HashSet<>();
|
||||
// Ruins itself reads <gameDir>/config/ruins_config, not NeoForge's
|
||||
// overrideable CONFIGDIR; mirror its active folder exactly.
|
||||
Path configured = FMLPaths.GAMEDIR.get().resolve("config/ruins_config");
|
||||
if (Files.isDirectory(configured)) {
|
||||
try (var paths = Files.walk(configured)) {
|
||||
paths.filter(Files::isRegularFile)
|
||||
.filter(path -> configured.relativize(path).getNameCount() == 2)
|
||||
.filter(path -> isTemplate(path.getFileName().toString()))
|
||||
.sorted().forEach(path -> add(discovered, sources, duplicateNames,
|
||||
path.getFileName().toString(), configured.relativize(path).toString()));
|
||||
} catch (IOException exception) {
|
||||
Rivencraft.LOGGER.warn("Could not read Ruins templates from {}", configured, exception);
|
||||
}
|
||||
}
|
||||
// Ruins extracts this archive on first launch. It also supplies the creative
|
||||
// pages before extraction has completed on a fresh installation.
|
||||
if (discovered.isEmpty() && !Files.isDirectory(configured)) {
|
||||
try (InputStream stream = RuinsTemplatePages.class.getClassLoader()
|
||||
.getResourceAsStream("assets/ruins_config.zip")) {
|
||||
if (stream != null) {
|
||||
try (ZipInputStream zip = new ZipInputStream(stream)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
String name = entry.getName();
|
||||
name = name.substring(name.lastIndexOf('/') + 1);
|
||||
if (isTemplate(name)) add(discovered, sources, duplicateNames,
|
||||
name, entry.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
Rivencraft.LOGGER.warn("Could not read bundled Ruins templates", exception);
|
||||
}
|
||||
}
|
||||
// Ruins' spawn event exposes only the basename. If two directories use
|
||||
// that basename, Rivencraft cannot identify which structure was chosen.
|
||||
// Disable the bridge instead of advertising pages with ambiguous effects.
|
||||
if (!duplicateNames.isEmpty()) {
|
||||
Rivencraft.LOGGER.error("Ruins compatibility disabled: duplicate template names {}. "
|
||||
+ "Give every .tml file a unique basename.", duplicateNames);
|
||||
names = Map.of();
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
names = Map.copyOf(discovered);
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
public static List<String> templates() {
|
||||
if (!initialized) refresh();
|
||||
List<String> templates = new ArrayList<>(names.values());
|
||||
templates.sort(Comparator.comparing(String::toLowerCase));
|
||||
return List.copyOf(templates);
|
||||
}
|
||||
|
||||
public static boolean isSymbol(ResourceLocation id) {
|
||||
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith(PREFIX);
|
||||
}
|
||||
|
||||
public static boolean contains(ResourceLocation id) {
|
||||
return isSymbol(id) && names.containsKey(id);
|
||||
}
|
||||
|
||||
public static ResourceLocation symbolId(String templateName) {
|
||||
if (templateName.isBlank()) throw new IllegalArgumentException("Ruins template has no usable name");
|
||||
// Hex is reversible and collision-free even for case-sensitive or Unicode
|
||||
// filenames; normalizing to lowercase would merge distinct templates.
|
||||
String key = HexFormat.of().formatHex(templateName.getBytes(StandardCharsets.UTF_8));
|
||||
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, PREFIX + "t_" + key);
|
||||
}
|
||||
|
||||
public static ResourceLocation featureId(String templateName) {
|
||||
return symbolId(templateName);
|
||||
}
|
||||
|
||||
public static String fallbackName(ResourceLocation symbolId) {
|
||||
String encoded = symbolId.getPath().substring(PREFIX.length());
|
||||
if (!encoded.startsWith("t_")) return encoded + " Ruin";
|
||||
String name;
|
||||
try {
|
||||
name = new String(HexFormat.of().parseHex(encoded.substring(2)), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
name = encoded;
|
||||
}
|
||||
return stripExtension(name).replace('_', ' ') + " Ruin";
|
||||
}
|
||||
|
||||
public static SymbolDefinition definition(String templateName) {
|
||||
ResourceLocation id = featureId(templateName);
|
||||
return new SymbolDefinition(SymbolDefinition.CURRENT_SCHEMA_VERSION,
|
||||
"symbol.rivencraft.ruins.template", "symbol.rivencraft.ruins.description",
|
||||
CATEGORY, Optional.of(new SymbolSemantics(SymbolSemantics.Role.FEATURE,
|
||||
new SymbolSemantics.ResourceValue(id))), "");
|
||||
}
|
||||
|
||||
private static boolean isTemplate(String name) {
|
||||
return name.toLowerCase(Locale.ROOT).endsWith(".tml");
|
||||
}
|
||||
|
||||
private static String stripExtension(String name) {
|
||||
return isTemplate(name) ? name.substring(0, name.length() - 4) : name;
|
||||
}
|
||||
|
||||
private static void add(Map<ResourceLocation, String> templates, Map<String, String> sources,
|
||||
Set<String> duplicateNames, String name, String source) {
|
||||
String previousSource = sources.putIfAbsent(name, source);
|
||||
if (previousSource != null && !previousSource.equals(source)) duplicateNames.add(name);
|
||||
ResourceLocation id = symbolId(name);
|
||||
String previous = templates.putIfAbsent(id, name);
|
||||
if (previous != null && !previous.equals(name)) {
|
||||
Rivencraft.LOGGER.warn("Ruins templates {} and {} share page ID {}; using {}",
|
||||
previous, name, id, previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ public final class RivencraftConfig {
|
||||
private static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
|
||||
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
|
||||
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
||||
private static final ModConfigSpec.ConfigValue<List<? extends String>> RANDOM_PAGE_ALLOWLIST;
|
||||
private static final ModConfigSpec.ConfigValue<List<? extends String>> RANDOM_PAGE_BLOCKLIST;
|
||||
private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
|
||||
private static final ModConfigSpec.BooleanValue INSTABILITY_ENABLED;
|
||||
private static final ModConfigSpec.IntValue INSTABILITY_DIFFICULTY;
|
||||
@@ -89,6 +91,21 @@ public final class RivencraftConfig {
|
||||
LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline link-panel page.")
|
||||
.defineInRange("link_panel_ink_cost", 2, 1, 16);
|
||||
builder.pop();
|
||||
builder.push("random_pages");
|
||||
RANDOM_PAGE_ALLOWLIST = builder.comment(
|
||||
"Optional Page paths allowed in new random Ages and newly generated library loot.",
|
||||
"The allowlist restricts only categories it mentions; unmentioned categories remain unrestricted.",
|
||||
"Example: [\"sky/static\"] restricts random sky styles to Static Sky.",
|
||||
"Full namespaced IDs are also accepted for compatibility.")
|
||||
.defineList("allowlist", List.<String>of(), () -> "sky/static",
|
||||
RivencraftConfig::validPageId);
|
||||
RANDOM_PAGE_BLOCKLIST = builder.comment(
|
||||
"Page paths excluded from new random Ages and newly generated library loot.",
|
||||
"The blocklist wins over the allowlist. Example: [\"sky/static\"].",
|
||||
"Existing Ages and Pages deliberately remain usable in hand-authored books.")
|
||||
.defineList("blocklist", List.<String>of(), () -> "sky/static",
|
||||
RivencraftConfig::validPageId);
|
||||
builder.pop();
|
||||
builder.push("serene_seasons");
|
||||
AUTO_WHITELIST_SERENE_AGES = builder.comment(
|
||||
"Automatically add Rivencraft Ages to Serene Seasons' dimension whitelist when loaded.",
|
||||
@@ -233,6 +250,14 @@ public final class RivencraftConfig {
|
||||
return LINK_PANEL_INK_COST.get();
|
||||
}
|
||||
|
||||
public static List<? extends String> randomPageAllowlist() { return RANDOM_PAGE_ALLOWLIST.get(); }
|
||||
public static List<? extends String> randomPageBlocklist() { return RANDOM_PAGE_BLOCKLIST.get(); }
|
||||
|
||||
private static boolean validPageId(Object value) {
|
||||
return value instanceof String id && !id.isBlank()
|
||||
&& ResourceLocation.tryParse(id.contains(":") ? id : "rivencraft:" + id) != null;
|
||||
}
|
||||
|
||||
public static boolean autoWhitelistSereneAges() {
|
||||
return AUTO_WHITELIST_SERENE_AGES.get();
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ public final class CrystalPortalGameTests {
|
||||
helper.assertTrue(plan.valid(), "A closed crystal frame must form a portal");
|
||||
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
helper.assertTrue(level.getBlockState(holderPos).getValue(BookReceptacleBlock.HAS_BOOK),
|
||||
"An occupied receptacle must expose its book state to client renderers");
|
||||
LinkTarget destination = new LinkTarget(GlobalPos.of(level.dimension(), centerTarget(helper)), 0, 0);
|
||||
holder.setDestination(destination);
|
||||
BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState());
|
||||
@@ -89,6 +91,12 @@ public final class CrystalPortalGameTests {
|
||||
"A north/south opening must only show its two player-facing sides");
|
||||
CrystalPortalNetwork.shutdown(level, holderPos);
|
||||
helper.assertTrue(level.getBlockState(center).isAir(), "Removing the book must release the portal");
|
||||
helper.assertTrue(holder.takeBook().is(ModItems.LINKING_BOOK.get()),
|
||||
"Removing the book must return the stored item");
|
||||
helper.assertTrue(!level.getBlockState(holderPos).getValue(BookReceptacleBlock.HAS_BOOK),
|
||||
"Removing the book must clear the client-visible model state");
|
||||
helper.assertTrue(!holder.getUpdateTag(level.registryAccess()).contains("Book"),
|
||||
"The block-entity update must also clear the old client-side book stack");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
|
||||
@@ -212,8 +212,10 @@ public final class LinkingGameTests {
|
||||
"A page-free book must select a supported legacy-inspired terrain profile");
|
||||
helper.assertValueEqual(first.generationVersion(), AgeDefinition.CURRENT_GENERATION_VERSION,
|
||||
"New Ages must record the current generation algorithm version");
|
||||
helper.assertValueEqual(first.traits(), AgeTraits.select(first.seed()),
|
||||
"A page-free book must persist the deterministic traits selected from its seed");
|
||||
helper.assertValueEqual(first.traits(), net.dnorberg.rivencraft.book.AgeEnvironmentCompiler.forTerrain(
|
||||
AgeTraits.select(first.seed()),
|
||||
net.dnorberg.rivencraft.book.AgeRuntimeProfiles.resolve(first.generationProfile())),
|
||||
"A page-free book must persist deterministic traits, with Cave sky/weather normalization");
|
||||
helper.assertTrue(first.traits().hasStoppedCelestialCycle()
|
||||
|| first.traits().celestialPeriod() >= AgeTraits.MIN_CELESTIAL_PERIOD
|
||||
&& first.traits().celestialPeriod() <= AgeTraits.MAX_CELESTIAL_PERIOD,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Small server-side guard against shipping missing model textures or the library template. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class Milestone7ResourceGameTests {
|
||||
private Milestone7ResourceGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void coreModelsResolveBundledTextures(GameTestHelper helper) {
|
||||
for (String model : List.of("book_binder", "ink_mixer", "writing_desk",
|
||||
"book_receptacle", "crystal_block", "crystal_portal")) {
|
||||
String path = "assets/rivencraft/models/block/" + model + ".json";
|
||||
try (InputStream stream = resource(path)) {
|
||||
helper.assertTrue(stream != null, "Missing packaged block model " + path);
|
||||
JsonObject json = JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8))
|
||||
.getAsJsonObject();
|
||||
if (!json.has("textures")) continue;
|
||||
for (var entry : json.getAsJsonObject("textures").entrySet()) {
|
||||
String texture = entry.getValue().getAsString();
|
||||
if (!texture.startsWith("rivencraft:")) continue;
|
||||
String texturePath = "assets/rivencraft/textures/"
|
||||
+ texture.substring("rivencraft:".length()) + ".png";
|
||||
helper.assertTrue(Milestone7ResourceGameTests.class.getClassLoader().getResource(texturePath) != null,
|
||||
"Model " + model + " references missing texture " + texturePath);
|
||||
}
|
||||
} catch (java.io.IOException exception) {
|
||||
throw new IllegalStateException("Cannot inspect " + path, exception);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(Milestone7ResourceGameTests.class.getClassLoader()
|
||||
.getResource("data/rivencraft/structure/mystcraft_library.nbt") != null,
|
||||
"The Legacy library template must be included in the mod JAR");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static InputStream resource(String path) {
|
||||
return Milestone7ResourceGameTests.class.getClassLoader().getResourceAsStream(path);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
|
||||
import net.dnorberg.rivencraft.book.AgeBiomeSelection;
|
||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
|
||||
@@ -71,6 +72,7 @@ public final class PageItem extends Item {
|
||||
categories.add(BlockSymbolPages.CATEGORY);
|
||||
categories.add(BlockSymbolPages.FLUID_CATEGORY);
|
||||
categories.add(CAVE_BIOME_CATEGORY);
|
||||
if (!RuinsTemplatePages.templates().isEmpty()) categories.add(RuinsTemplatePages.CATEGORY);
|
||||
return List.copyOf(categories);
|
||||
}
|
||||
|
||||
@@ -78,6 +80,11 @@ public final class PageItem extends Item {
|
||||
if (category.equals(CAVE_BIOME_CATEGORY)) {
|
||||
return createCreativeBiomePage(ResourceLocation.withDefaultNamespace("lush_caves"));
|
||||
}
|
||||
if (category.equals(RuinsTemplatePages.CATEGORY)) {
|
||||
return RuinsTemplatePages.templates().stream().findFirst()
|
||||
.map(PageItem::createCreativeRuinsPage)
|
||||
.orElseGet(() -> createBundledCreativePage("feature/villages"));
|
||||
}
|
||||
if (category.equals(BlockSymbolPages.CATEGORY) || category.equals(BlockSymbolPages.FLUID_CATEGORY)) {
|
||||
return createCreativeBlockPage(ResourceLocation.withDefaultNamespace(
|
||||
category.equals(BlockSymbolPages.FLUID_CATEGORY) ? "water" : "stone"));
|
||||
@@ -93,6 +100,7 @@ public final class PageItem extends Item {
|
||||
public static List<ItemStack> creativeSymbolPages(HolderLookup.Provider registries) {
|
||||
List<ItemStack> pages = creativeBundledAndBiomePages(registries);
|
||||
BlockSymbolPages.availableBlocks().forEach(id -> pages.add(createCreativeBlockPage(id)));
|
||||
RuinsTemplatePages.templates().forEach(name -> pages.add(createCreativeRuinsPage(name)));
|
||||
return List.copyOf(pages);
|
||||
}
|
||||
|
||||
@@ -115,6 +123,9 @@ public final class PageItem extends Item {
|
||||
return BlockSymbolPages.availableBlocks(category).stream()
|
||||
.map(PageItem::createCreativeBlockPage).toList();
|
||||
}
|
||||
if (category.equals(RuinsTemplatePages.CATEGORY)) {
|
||||
return RuinsTemplatePages.templates().stream().map(PageItem::createCreativeRuinsPage).toList();
|
||||
}
|
||||
Set<ResourceLocation> caveBiomes = category.equals(BIOME_CATEGORY) || category.equals(CAVE_BIOME_CATEGORY)
|
||||
? caveBiomeIds(registries) : Set.of();
|
||||
return creativeBundledAndBiomePages(registries).stream()
|
||||
@@ -180,6 +191,14 @@ public final class PageItem extends Item {
|
||||
return page;
|
||||
}
|
||||
|
||||
public static ItemStack createCreativeRuinsPage(String templateName) {
|
||||
var symbolId = RuinsTemplatePages.symbolId(templateName);
|
||||
ItemStack page = create(PageContent.symbol(new SymbolReference(symbolId)));
|
||||
page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
|
||||
RuinsTemplatePages.definition(templateName).display(symbolId));
|
||||
return page;
|
||||
}
|
||||
|
||||
private static Map<String, CreativeSymbol> loadCreativeSymbols() {
|
||||
String resource = "/assets/rivencraft/creative_symbol_pages.json";
|
||||
try (var stream = PageItem.class.getResourceAsStream(resource)) {
|
||||
@@ -321,6 +340,10 @@ public final class PageItem extends Item {
|
||||
return Component.translatable(metadata.nameTranslationKey()).withStyle(ChatFormatting.OBFUSCATED);
|
||||
}
|
||||
return reference.id().getNamespace().equals(Rivencraft.MOD_ID)
|
||||
&& RuinsTemplatePages.isSymbol(reference.id())
|
||||
? Component.translatableWithFallback(metadata.nameTranslationKey(),
|
||||
RuinsTemplatePages.fallbackName(reference.id()))
|
||||
: reference.id().getNamespace().equals(Rivencraft.MOD_ID)
|
||||
&& reference.id().getPath().startsWith("biome/")
|
||||
? Component.translatableWithFallback(metadata.nameTranslationKey(),
|
||||
BiomeSymbolPages.fallbackName(reference.id()))
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.util.datafix.DataFixTypes;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
|
||||
/** World-persistent OP-only visibility switch for Book Binder writing diagnostics. */
|
||||
public final class BookBinderDiagnostics extends SavedData {
|
||||
private static final String DATA_NAME = "rivencraft_binder_diagnostics";
|
||||
private static final Factory<BookBinderDiagnostics> FACTORY = new Factory<>(
|
||||
BookBinderDiagnostics::new, BookBinderDiagnostics::load, DataFixTypes.SAVED_DATA_MAP_DATA);
|
||||
private boolean enabled;
|
||||
|
||||
public static BookBinderDiagnostics get(ServerLevel level) {
|
||||
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
|
||||
}
|
||||
|
||||
public boolean enabled() { return enabled; }
|
||||
|
||||
public boolean visibleTo(ServerPlayer player) {
|
||||
return visibleTo(player.hasPermissions(2));
|
||||
}
|
||||
|
||||
boolean visibleTo(boolean operator) { return enabled && operator; }
|
||||
|
||||
public boolean setEnabled(boolean enabled) {
|
||||
if (this.enabled == enabled) return false;
|
||||
this.enabled = enabled;
|
||||
setDirty();
|
||||
return true;
|
||||
}
|
||||
|
||||
static BookBinderDiagnostics load(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
BookBinderDiagnostics data = new BookBinderDiagnostics();
|
||||
data.enabled = tag.getBoolean("Enabled");
|
||||
return data;
|
||||
}
|
||||
|
||||
@Override public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
tag.putBoolean("Enabled", enabled);
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Binder diagnostics are world-persistent but never visible to non-operators. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class BookBinderDiagnosticsGameTests {
|
||||
private BookBinderDiagnosticsGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void binderMessagesDefaultOffAndPersist(GameTestHelper helper) {
|
||||
BookBinderDiagnostics data = BookBinderDiagnostics.get(helper.getLevel());
|
||||
helper.assertFalse(data.enabled(), "Binder diagnostic messages must default to off");
|
||||
helper.assertTrue(data.setEnabled(true), "An OP toggle must change the world setting");
|
||||
helper.assertFalse(data.setEnabled(true), "Repeating a toggle must not change the setting");
|
||||
CompoundTag saved = data.save(new CompoundTag(), helper.getLevel().registryAccess());
|
||||
BookBinderDiagnostics restored = BookBinderDiagnostics.load(saved, helper.getLevel().registryAccess());
|
||||
helper.assertTrue(restored.enabled(), "The OP setting must survive world reload");
|
||||
helper.assertTrue(restored.visibleTo(true), "An OP must see enabled binder diagnostics");
|
||||
helper.assertFalse(restored.visibleTo(false), "A non-OP must not see enabled binder diagnostics");
|
||||
helper.assertTrue(restored.setEnabled(false), "An OP toggle must disable messages");
|
||||
helper.assertFalse(restored.visibleTo(true), "Disabled messages must be invisible even to OPs");
|
||||
data.setEnabled(false);
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
private int grammarErrorStripIndex = -1;
|
||||
private int grammarInstability;
|
||||
private int grammarIncompleteCount;
|
||||
private int diagnosticsVisible;
|
||||
private WritingGrammar.Result serverGrammar;
|
||||
|
||||
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||
@@ -62,34 +63,38 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() {
|
||||
return clientSide || binder == null ? grammarErrorCode : binderDiagnostic()
|
||||
return clientSide || binder == null ? grammarErrorCode : !diagnosticsVisibleToAuthor() ? -1 : binderDiagnostic()
|
||||
.map(diagnostic -> diagnostic.code().ordinal()).orElse(-1);
|
||||
}
|
||||
@Override public void set(int value) { grammarErrorCode = value; }
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() {
|
||||
return clientSide || binder == null ? grammarErrorStripIndex : binderDiagnostic()
|
||||
return clientSide || binder == null ? grammarErrorStripIndex : !diagnosticsVisibleToAuthor() ? -1 : binderDiagnostic()
|
||||
.map(diagnostic -> binder.writingStripIndex(diagnostic.index())).orElse(-1);
|
||||
}
|
||||
@Override public void set(int value) { grammarErrorStripIndex = value; }
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() {
|
||||
return clientSide || binder == null ? grammarInstability
|
||||
return clientSide || binder == null ? grammarInstability : !diagnosticsVisibleToAuthor() ? 0
|
||||
: serverGrammar().writingInstability();
|
||||
}
|
||||
@Override public void set(int value) { grammarInstability = value; }
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() {
|
||||
return clientSide || binder == null ? grammarIncompleteCount
|
||||
return clientSide || binder == null ? grammarIncompleteCount : !diagnosticsVisibleToAuthor() ? 0
|
||||
: (int) serverGrammar().diagnostics().stream()
|
||||
.filter(diagnostic -> diagnostic.severity()
|
||||
== WritingGrammar.Severity.SEEDED_COMPLETION).count();
|
||||
}
|
||||
@Override public void set(int value) { grammarIncompleteCount = value; }
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() { return clientSide ? diagnosticsVisible : diagnosticsVisibleToAuthor() ? 1 : 0; }
|
||||
@Override public void set(int value) { diagnosticsVisible = value; }
|
||||
});
|
||||
addSlot(new BinderSlot(BookBinderBlockEntity.COVER, 8, 27, 64));
|
||||
addSlot(new BinderSlot(BookBinderBlockEntity.LINK_PANEL, -1000, -1000, 1));
|
||||
addSlot(new BinderSlot(BookBinderBlockEntity.NOTEBOOK, -1000, -1000, 1));
|
||||
@@ -130,7 +135,12 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
}
|
||||
public int grammarInstability() { return grammarInstability; }
|
||||
public int grammarIncompleteCount() { return grammarIncompleteCount; }
|
||||
public boolean diagnosticsVisible() { return diagnosticsVisible != 0; }
|
||||
public int grammarErrorStripIndex() { return grammarErrorStripIndex; }
|
||||
private boolean diagnosticsVisibleToAuthor() {
|
||||
return bookAuthor instanceof ServerPlayer serverPlayer
|
||||
&& BookBinderDiagnostics.get(serverPlayer.serverLevel()).visibleTo(serverPlayer);
|
||||
}
|
||||
public String title() { return binder == null ? "" : binder.title(); }
|
||||
public boolean setTitle(Player player, String title) {
|
||||
if (clientSide || !stillValid(player) || title == null
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
|
||||
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||
import net.minecraft.world.level.Level;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Pseudo;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
|
||||
/** Increase Ruins' per-chunk attempt loop only for Ages with duplicated Ruins pages. */
|
||||
@Pseudo
|
||||
@Mixin(targets = "atomicstryker.ruins.common.RuinGenerator", remap = false)
|
||||
public abstract class RuinsAttemptMixin {
|
||||
@ModifyExpressionValue(method = "generateNormal", at = @At(value = "FIELD",
|
||||
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNormal:I"), remap = false)
|
||||
private int rivencraft$normalAttempts(int configured, Level level) {
|
||||
return RuinsCompatibility.attemptsPerChunk(level.dimension().location(), configured);
|
||||
}
|
||||
|
||||
@ModifyExpressionValue(method = "generateNether", at = @At(value = "FIELD",
|
||||
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNether:I"), remap = false)
|
||||
private int rivencraft$netherAttempts(int configured, Level level) {
|
||||
return RuinsCompatibility.attemptsPerChunk(level.dimension().location(), configured);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||
import net.dnorberg.rivencraft.compat.RuinsEventAdapter;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import org.spongepowered.asm.mixin.Final;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Pseudo;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/** Ruins defaults to a fixed vanilla whitelist, so authored Ages need a narrow override. */
|
||||
@Pseudo
|
||||
@Mixin(targets = "atomicstryker.ruins.common.FileHandler", remap = false)
|
||||
public abstract class RuinsDimensionMixin {
|
||||
@Shadow @Final private ResourceLocation dimension;
|
||||
@Shadow @Final private HashMap<String, HashSet<?>> templates;
|
||||
|
||||
@Inject(method = "allowsDimension", at = @At("HEAD"), cancellable = true, remap = false)
|
||||
private void rivencraft$allowAuthoredAge(String path, CallbackInfoReturnable<Boolean> result) {
|
||||
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||
result.setReturnValue(RuinsCompatibility.allowsAge(dimension));
|
||||
}
|
||||
}
|
||||
|
||||
@Inject(method = "getTemplate", at = @At("HEAD"), cancellable = true, remap = false)
|
||||
private void rivencraft$selectAuthoredTemplate(RandomSource random, String biome,
|
||||
CallbackInfoReturnable<Object> result) {
|
||||
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||
result.setReturnValue(RuinsEventAdapter.chooseTemplate(dimension, templates.get(biome), random));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import org.spongepowered.asm.mixin.Final;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Pseudo;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/** An authored Ruins page overrides a template's vanilla-only dimension list for that Age. */
|
||||
@Pseudo
|
||||
@Mixin(targets = "atomicstryker.ruins.common.RuinTemplate", remap = false)
|
||||
public abstract class RuinsTemplateDimensionMixin {
|
||||
@Shadow @Final private String name;
|
||||
|
||||
@Inject(method = "acceptsDimension", at = @At("HEAD"), cancellable = true, remap = false)
|
||||
private void rivencraft$allowAuthoredAge(String dimensionPath, CallbackInfoReturnable<Boolean> result) {
|
||||
ResourceLocation dimension = ResourceLocation.fromNamespaceAndPath("rivencraft", dimensionPath);
|
||||
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||
result.setReturnValue(RuinsCompatibility.permits(dimension, RuinsTemplatePages.featureId(name)));
|
||||
}
|
||||
}
|
||||
}
|
||||