Compare commits

..
Author SHA1 Message Date
dnorberg be8440ddb5 Port collections, dropped books, return links and Writing Desk to 26.3
Build / build (push) Waiting to run
2026-11-05 22:04:24 -05:00
dnorberg f13bedd26f Port seeded Age completion and Accelerated pages to 26.3 beta
Build / build (push) Waiting to run
2026-11-05 15:19:11 -05:00
dnorberg 40770841ee Port celestial systems and seeded Age completion to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-04 18:08:12 -05:00
dnorberg 38e40ddba3 Port seeded sky colors and Normal Time celestial controls to 26.3
Build / build (push) Canceled after 0s
2026-11-03 16:56:07 -05:00
dnorberg a7a73d3c17 Port Age lighting and animated sky styles to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-02 15:49:58 -05:00
dnorberg 48fbae9be9 Port structures, weather, vegetation and safe surface arrivals to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-02 09:30:22 -05:00
dnorberg c223f3fdca Port authored Age features and Star Fissures to 26.3 beta
Build / build (push) Canceled after 0s
Advance to 1.0.1.54-beta.1 after player validation. Port material, vegetation, lake, terrain-volume, crystal, Dense Ores and fissure features with saved plans. Update beta documentation and release notes.

Refs #98, #104
2026-11-02 01:51:20 -05:00
dnorberg d75154fa9b Port seeded random Ages and authored Rocks to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-01 13:25:42 -05:00
dnorberg 9ccec18c18 Port dry Cave Ages and safe landing to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-01 01:34:14 -05:00
dnorberg d9464fad76 Add Gitea API release publishing to CLI helper
Build / build (push) Canceled after 0s
2026-10-31 20:46:41 -04:00
dnorberg d6e9a58a24 Omit empty CurseForge relations in release uploads
Build / build (push) Canceled after 0s
2026-10-31 20:37:48 -04:00
dnorberg fa294be685 Port Flat and Void Age terrain to Minecraft 26.3 beta
Build / build (push) Canceled after 0s
2026-10-31 20:35:08 -04:00
dnorberg cb31156584 Add Modrinth and CurseForge API publishing helper
Build / build (push) Canceled after 0s
2026-10-31 02:53:32 -04:00
dnorberg c23b057f51 Advance Minecraft 26.3 port to authored Age beta
Build / build (push) Canceled after 0s
2026-10-31 02:38:22 -04:00
dnorberg 9ab8d3de4a Port Binder and Ink Mixer interactions to 26.3 alpha
Build / build (push) Canceled after 0s
2026-10-30 15:55:43 -04:00
dnorberg 56005ac512 Port crystal growth, pages, ink vials and link crafting to 26.3
Build / build (push) Canceled after 0s
2026-10-29 15:07:46 -04:00
dnorberg 9ff931afa9 Add 26.3 descriptive book binding and travel
Build / build (push) Canceled after 0s
2026-10-29 14:32:50 -04:00
dnorberg eda19de43c Prepare Minecraft 26.3 technical alpha 1.0.1.2-alpha.1
Build / build (push) Canceled after 0s
2026-10-29 14:25:39 -04:00
dnorberg 0fb2f7c1bb Add incomplete Minecraft 26.3 port prototype
Build / build (push) Canceled after 0s
2026-10-27 19:09:19 -04:00
dnorberg 4f07f675a4 Refresh public links and include development screenshots
Build / build (push) Canceled after 0s
2026-10-26 14:25:18 -04:00
dnorberg b8be99606f Add configurable random Page lists and safe fallbacks (#95) 2026-10-26 14:25:13 -04:00
dnorberg 98595c83ea Complete Milestone 7 acceptance and binder diagnostics (#84)
Build / build (push) Canceled after 0s
2026-10-22 22:32:12 -04:00
dnorberg e7e2cf7e5a Add Milestone 7 regression and documentation review (#83)
Build / build (push) Canceled after 0s
2026-10-22 21:37:50 -04:00
dnorberg 77f15f8599 Use number pages for celestial modifiers (#94)
Build / build (push) Canceled after 0s
2026-10-22 15:33:37 -04:00
248 changed files with 21685 additions and 362 deletions

No files matched your search

+4
View File
@@ -32,6 +32,10 @@ bin/
### Mac OS ###
.DS_Store
### Python tools ###
__pycache__/
*.pyc
### Minecraft Modding ###
run/
logs/
+105 -267
View File
@@ -1,292 +1,130 @@
# 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 independently developed 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.
The gameplay guide below describes the **Minecraft 1.21.1 release**. A separate,
incomplete **Minecraft 26.3 beta** is available: Java 25, NeoForge 26.3.0.99,
and a first-party Age backend (no Dynamic Dimensions or Bad Packets). It now
supports authored terrain/biomes, structure and vegetation pages, independent
Age weather/lighting, seeded and colorable Overworld/Nether/End/Static skies,
independent Sun/Moon controls, multiple bodies and eclipses, seeded biome and
feature completion, and random structure selection. Nether/End noise-biome
materials now follow their native rules. The beta does not yet provide full
1.21.1 gameplay or optional-integration parity.
See the [26.3 beta guide](ports/mc263/README.md) and
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.106-beta.1.md).
Keep the builds and their saves separate, and back up worlds before updating.
## Book Binder
## Getting started
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.
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.
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`.
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.
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.
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.
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.
## Requirements and optional integrations
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.
Install the same gameplay mods on the client and server:
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.
- 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.
## Linking Book
Rivencraft works without any of these optional integrations:
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.
| 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. |
Successful book travel plays Mystcraft Legacy's original linking sound at both
the departure and arrival locations.
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.
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.
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.
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.
## Saves, settings, and current limits
## Descriptive Book
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.
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.
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.
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.
## Project and development
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.
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.
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)
Ruins is optional. When installed, its configured structures have individual
authorable pages; see [Ruins compatibility](docs/RUINS_COMPATIBILITY.md).
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).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+11 -10
View File
@@ -11,7 +11,7 @@ suffixes such as `_2` and `_3`.
The selected terrain profile, biome distribution, lighting, weather, celestial
period, celestial angle, and phase are deterministic and become part of the
saved definition. A five-by-five cobblestone platform with clear headroom is
saved definition. A five-by-five non-dropping Crystal Platform with clear headroom is
prepared at a safe arrival point. The completed book and every copy carrying
the same UUID resolve to that definition.
@@ -42,10 +42,11 @@ on demand when an administrator previously unloaded it. Unloading retains the
definition and world folder, so existing books continue to reference the same
Age and later reload the same terrain.
Rivencraft does not automatically delete Ages. Safe permanent deletion needs a
complete reference index for books in inventories, containers, Book Stands,
and unloaded chunks. Until that exists, retaining the definition prevents an
administrator action from silently orphaning books.
Rivencraft does not automatically delete Ages. The operator-only creative Link
Modifier can deliberately retire an unoccupied Age: it unloads the dimension,
moves its data to a sibling `<world>-retired-ages/<uuid>` archive, and records
the UUID as dead so existing books cannot silently regenerate it. Back up the
whole world first.
Destination-preview chunk loaders are temporary. Rivencraft releases them when
the book closes, the player travels, the player disconnects, the destination
@@ -101,8 +102,8 @@ Use this sequence when a book cannot load an existing Age:
matching dimension data and authoritative world saved data from the same
backup generation. Do not combine them from different backups.
There is intentionally no delete command in Milestone 3. Do not manually
delete an Age definition or rename its dimension folder: books may exist in
inventories, containers, Book Stands, or unloaded chunks, and would be
orphaned. `/rivencraft age unload` is the supported reversible maintenance
operation.
Do not manually delete an Age definition or rename its dimension folder:
books may exist in inventories, containers, Book Stands, or unloaded chunks.
`/rivencraft age unload` remains the reversible maintenance operation. The
Link Modifier's retirement control is not reversible through a book UI;
recover its archived data and registry together from a full-world backup.
+98
View File
@@ -0,0 +1,98 @@
# Current implementation (Milestone 7 development)
This is the maintained technical overview of Rivencraft as of the 1.0.0.2
slice. It describes implemented behavior, not every behavior in Mystcraft
Legacy. [The README](../README.md) is the player introduction;
[milestone summaries](MILESTONES.md) explain how the implementation arrived
here. The linked deep dives are retained as historical audits and should be
read with their version dates in mind.
## Books, linking, and previews
- Linking Books bind a named position and direction. Same-dimension use needs
Intra Linking. Descriptive Books refer to a persistent Age UUID and keep
their authored page order. Copies keep that identity.
- Book screens use Legacy-inspired page spreads and destination panels. `AUTO`
selects optional Immersive Portals first, then Create/Ponder, then the built-in
animation. Travel is validated on the server regardless of renderer.
- Handheld travel drops the active book at its departure point as a visible
book entity. Book Stands and crystal portals keep the book in place. Disarm
drops carried items; Maintain Momentum rotates and carries velocity.
- A vanilla Lectern converts to a Book Stand when given a Rivencraft book or
page. A Book Receptacle in a connected Crystal Block frame activates a
vertical or horizontal portal. Immersive Portals can display a live view;
otherwise the Legacy-style animated surface remains. Portal arrivals use
existing safe footing; a Descriptive portal does not generate a platform.
- Automatic Return Links default to `DISABLED`, with `ON_CREATION` and `ALWAYS`
server options. Linking Books do not generate platforms by default; a
creative Link Modifier can explicitly enable that property.
See [preview modes](PREVIEW_COMPATIBILITY.md), [Legacy M7 audit](LEGACY_M7_AUDIT.md),
and [Age administration](AGES.md).
## Ages, writing, and structures
- Dynamic Dimensions AR Edition creates Age worlds. Each Age has a saved UUID,
safe dimension ID, seed, spawn, traits, versioned generation plan, and
optional season mode. Page-free or Link-Panel-only books get seeded random
choices. Authored supported pages override the appropriate choices.
- Terrain choices are Standard, Amplified, Island, Cave, Flat, and Void. Cave
terrain uses cave/Nether biomes and an appropriate spawn search; normal
terrain starts from exposed surfaces. If no safe arrival site exists, the
Age-creation path can construct a non-dropping Crystal Platform at Y=63.
- The server catalog accepts typed data-pack symbols. The Binder preserves
written order and diagnoses conflicts. Its status readout is off by default;
`/rivencraft binder messages enable` makes it visible only to OPs. Symbol
Pages are composed at runtime from Legacy glyph assets. Number pages 0–24
set color strength or, immediately before a Length, Angle, Phase, or Size
modifier, its numeric value.
Celestial pages can create multiple suns/moons with independent color, size,
angle, period, phase, and direction; authored eclipses affect light.
- The Writing Desk stores tabs and copies known Symbol Pages with paper and
ink. Notebooks hold ordered pages; the Binder consumes a Link Panel and cover
to make a finished Descriptive Book. The Ink Mixer makes Link Panels and
rolls Disarm, Intra Linking, and Maintain Momentum from additives. Blank
Pages are internal bases, not a craftable or JEI-listed output.
- Features include terrain additions, decorations, configurable block/fluid
materials, ores, vanilla structures including newer 1.21 structures,
Legacy-style libraries, crystals, obelisks, and star fissures. Optional Ruins
templates gain authorable pages. Some biome/terrain combinations or feature
placements are deliberately unsupported and are diagnosed during binding.
See [symbol semantics](SYMBOL_SEMANTICS.md), [writing data](WRITING_DATA.md),
[generation profiles](AGE_PROFILES.md), and [library/Ruins rules](RUINS_COMPATIBILITY.md).
## Instability and optional mods
Invalid or excessive writing and valuable generated blocks can add
instability. Block abundance is profiled in loaded chunks, so its contribution
and selected effects can change as the Age is explored; the authored plan is
not rewritten. Effects are bounded to loaded Ages and include status effects,
colored decay, lightning, explosions, meteors, burning, and erosion. Operator
commands expose the score and providers; the book GUI does not. See
[scoring](INSTABILITY_SCORE.md) and [runtime policy](INSTABILITY_RUNTIME.md).
JEI, Create/Ponder, Immersive Portals, TerraBlender, Serene Seasons with
GlitchCore, and Ruins are optional. TerraBlender can supply biomes from any mod
using its eligible regions; Biomes O' Plenty is a test example. Serene Seasons
has per-Age normal/no/eternal modes and live Age enrollment. Ecliptic Seasons
is not integrated because its 1.21.1 API lacks supported live registration and
per-Age calendar control. See the [optional-mod matrix](MILESTONE_7_REGRESSION.md).
## Persistence and boundaries
An Age registry definition and its dimension folder belong to the **same
whole-world backup**. Known older book/writing, plan, workstation, and Age
formats have migration or stable-default handling. Unknown symbol IDs remain
in books; missing registry objects or removed dimension mods can still prevent
world loading. The OP-only Link Modifier can retire an unoccupied Age. That
operation marks its UUID dead and moves its dimension data to a sibling
`<world>-retired-ages/<uuid>` folder; it is not ordinary book maintenance.
Do not use it without a backup.
This is not an exact port of every Legacy generator, page, hazard, or client
renderer. Modern safety budgets and NeoForge's dimension/biome APIs require
adaptations. The [Milestone 7 regression matrix](MILESTONE_7_REGRESSION.md)
separates tested behavior from unverified multiplayer/restart/optional-mod
combinations. The [Milestone 7 acceptance record](MILESTONE_7_ACCEPTANCE.md)
records release evidence and remaining validation limits.
+4
View File
@@ -1,5 +1,9 @@
# GameTest suites
The current Milestone 7 results and manual handoffs are in the
[regression matrix](MILESTONE_7_REGRESSION.md). This file documents the test
mechanics and older scenario-specific handoffs.
The default dedicated-server run is a small smoke suite. Its Rivencraft tests
cover core linking, deterministic Age definition, writing persistence, legacy
data migration, workstation transactions, and forged/replayed menu actions.
+66
View File
@@ -0,0 +1,66 @@
# Milestones at a glance
This is the condensed milestone history. The [current implementation](CURRENT_IMPLEMENTATION.md)
is authoritative for what the latest development build does; older acceptance
and audit files remain available as dated evidence, not competing current
instructions. [Gitea milestones](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestones)
hold the live issue status.
## 1 — Bootstrap (complete)
Established the NeoForge 1.21.1/Java 21 project, mod identity, registries,
assets, build, and first automated checks. The port deliberately shares
Mystcraft's concepts while maintaining its own modern package and data model.
See [porting principles](PORTING.md) and [asset provenance](ASSET_PROVENANCE.md).
## 2 — Books and linking (complete)
Introduced persistent Linking and Descriptive Books, conversion of vanilla
Lecterns into Book Stands, named books and Ages, safe travel, destination
previews, and a configurable Return Link policy. Later milestones refined
renderer selection and portal travel. See [preview compatibility](PREVIEW_COMPATIBILITY.md).
## 3 — Ages (complete)
Added persistent runtime dimensions, deterministic random Age traits,
name-derived unique dimension folders, terrain/biome choices, spawn recovery,
and Age lifecycle commands. See [Age administration](AGES.md),
[runtime profiles](AGE_PROFILES.md), and [random weights](RANDOM_AGE_WEIGHTS.md).
## 4 — Writing system (complete)
Introduced Symbol Pages, Link Panels, Notebooks, the Writing Desk, Book Binder,
and Ink Mixer. Writing order and unknown symbol references persist instead of
being inferred from UI text. See [writing data](WRITING_DATA.md),
[descriptive-book presentation](DESCRIPTIVE_BOOK_PRESENTATION.md), and
[Ink Mixer audit](INK_MIXER_MODIFIER_AUDIT.md).
## 5 — Age grammar (complete for the supported subset)
Compiled supported pages into frozen Age plans: terrain, biome distribution,
environment, features, block/fluid materials, structures, crystals, and
fissures. Added optional TerraBlender and Serene Seasons adapters. This was
not a claim of complete Legacy grammar parity. See [M5 acceptance](MILESTONE_5_ACCEPTANCE.md),
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md),
[symbol schema](SYMBOL_SEMANTICS.md), [TerraBlender](TERRABLENDER_COMPATIBILITY.md),
and [Serene Seasons](SERENE_SEASONS_COMPATIBILITY.md).
## 6 — Instability (complete)
Added authored and live block-abundance scoring, frozen provider plans,
operator diagnostics, bounded status effects, decay, lightning, explosions,
meteors, burning, and erosion. Safety budgets are deliberate modern
adaptations. See [M6 acceptance](MILESTONE_6_ACCEPTANCE.md),
[Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md), and
[runtime policy](INSTABILITY_RUNTIME.md).
## 7 — Structures and polish (complete in 1.0.0.2)
Added Legacy-layout Age libraries, page-bearing stands, crystal portals and
receptacles, the handheld dropped-book entity, data-driven recipes,
advancements, asset/audio polish, optional Ruins, compatibility updates, and
expanded sky/glyph pages. Issue #83 covered regression, migration, and document
consolidation; issue #84 records the final milestone acceptance. See the
[M7 acceptance](MILESTONE_7_ACCEPTANCE.md), [M7 Legacy audit](LEGACY_M7_AUDIT.md),
[M7 regression matrix](MILESTONE_7_REGRESSION.md), and
[M7 compatibility review](MILESTONE_7_COMPATIBILITY_REVIEW.md).
+72
View File
@@ -0,0 +1,72 @@
# Milestone 7 acceptance — 1.0.0.2
Milestone 7 is complete for the supported NeoForge 1.21.1 feature set. This is
not a claim of total Mystcraft Legacy parity or exhaustive optional-mod
certification. The [current implementation](CURRENT_IMPLEMENTATION.md) is the
up-to-date behavior summary; the [regression matrix](MILESTONE_7_REGRESSION.md)
separates automated evidence from manual scenarios.
## Scope and issue gate
The Gitea audit for issue #84 found no other open issues. Required existing
#14 (cave terrain), #43 (glyph numbers), #44 (sky), and #60 (old-save policy)
were closed. M7 implementation and review issues #75–#83, #85–#90, and
later additions #91–#94 were closed before this acceptance. No required
issue was silently deferred.
| Legacy concept | Rivencraft implementation or deliberate difference |
| --- | --- |
| Cave terrain and writing | Cave terrain uses its cave/Nether biome set and a modern Y=64-start spawn search. Existing non-cave terrain still supports caves as features. See [Age profiles](AGE_PROFILES.md) and [grammar](WRITING_GRAMMAR.md). |
| Libraries | The recognizable Legacy small-library layout and sparse candidate spacing are ported as a structure template with a chest and up to five page-bearing Book Stands. It generates only in Ages and only on suitable surfaces; player-selectable Legacy or Age-only loot is supported. See [Legacy M7 audit](LEGACY_M7_AUDIT.md). |
| Crystal portal and Receptacle | Connected Crystal Blocks support vertical and horizontal portals. The held book stays in the Receptacle; removing it removes the surface. Rivencraft validates loaded bounds and block protection, saves owner pointers, and uses book-travel rules. Optional Immersive Portals supplies a live destination view, with an animated fallback while unavailable. |
| Handheld Linking/Descriptive Books | The used book remains behind as a rendered book entity at the origin. The exact Legacy entity pickup rule is not copied: modern contact pickup is used. Return Links default to disabled but remain configurable. |
| Recipes and discovery | JSON crafting recipes and recipe-book unlocks cover the supported writing/crystal progression. Optional JEI covers the Binder, Writing Desk, and Ink Mixer; a Lectern conversion has no JEI guide. Blank Pages are not craftable. Optional Create/Ponder teaches portal construction. See [JEI review](MILESTONE_7_JEI_REVIEW.md). |
| Models, sounds, and advancements | Legacy-sourced assets are credited in [asset provenance](ASSET_PROVENANCE.md); 1.21.1-compatible models and renderer adaptations are documented in the [visual audit](MILESTONE_7_VISUAL_AUDIT.md). Server-owned advancements cover book, writing, Age, and portal progress; the unimplemented Quinn equivalent is explicitly omitted in [advancement notes](MILESTONE_7_ADVANCEMENTS.md). |
| Numbers, sky, and extra items | Base-25 number glyphs, celestial modifiers and eclipses, Symbol Portfolios, the OP-only Link Modifier, and optional Ruins pages were follow-up additions. Legacy page semantics are adapted to the modern authoring plan rather than a binary copy of 1.12.2 worldgen. |
## Compatibility and migration
NeoForge **21.1.264** is the minimum 21.1 build. A player passed the
singleplayer client with the optional stack, then a local dedicated server
including Immersive Portals, before the minimum was raised. See the
[compatibility decision](MILESTONE_7_COMPATIBILITY_REVIEW.md). JEI
19.69.0.457 and Serene Seasons 10.1.0.9 received their own player checks;
Create/Ponder, Immersive Portals, TerraBlender/biome add-ons, Serene Seasons,
JEI, and Ruins remain optional. Ecliptic Seasons is deliberately not
integrated: its 1.21.1 public API does not support the required live Age
registration and per-Age calendar control. Missing optional mods must not
prevent the core mod from loading.
Known older writing/book, Age-plan, and workstation formats have explicit
migration or stable-default handling. An Age registry and its dimension
folders must be backed up together. Removed mods, missing custom registry
objects, and arbitrary prerelease saves are **not** guaranteed to load.
Retiring an Age through the creative Link Modifier archives its data and
invalidates its UUID; that intentional destructive operation is not a
migration. See the [old-save boundary](MILESTONE_7_REGRESSION.md#old-save-boundary-60).
## Release validation and limits
The 0.6.11.0 regression candidate passed `./gradlew --offline build` and
fresh-world GameTest runs: core **91/91**, generation **129/129**, and
environment **103/103**. The player reported an in-game pass for that
candidate. The 1.0.0.1 release candidate additionally allows Number Pages to
set Celestial Size. The 1.0.0.2 release makes Binder status diagnostics an
OP-only, default-off world setting. Its offline build and core GameTests
passed (93/93), and the player reported an in-game pass. The
preceding 1.0.0.0 candidate passed core and generation GameTests. Its
environment archive stalled in the first 50-test batch, repeatedly printing
its field without completing; it was stopped and is **not** counted as a
1.0.0.0–1.0.0.2 pass. The previous 0.6.11.0 run of that same suite passed 103/103.
The focused sky, season, and number-page in-game checks in the regression
matrix remain useful regression checks, not claimed full-matrix passes. The
Gitea release changelog records the tested JAR and verified SHA-256.
These results do not prove every two-player race, full restart, migration
from every development world, or optional-mod combination. The specific
manual matrix remains in [M7 regression](MILESTONE_7_REGRESSION.md); those
broader combinations are known validation limits, not claimed passes. Modern
chunk and tick budgets, safer portal validation, 1.21.1 world-height and
biome APIs, and the dynamic-dimension backend are deliberate deviations from
Legacy. A complete Legacy worldgen or link-property port is outside this
milestone.
+79
View File
@@ -0,0 +1,79 @@
# Milestone 7 regression and migration matrix (issue #83)
This is the dated regression record for the 0.6.11.0 candidate. The
[Milestone 7 acceptance](MILESTONE_7_ACCEPTANCE.md) owns the final sign-off. A passing
GameTest is server-side evidence; it is not proof of client rendering,
multiplayer timing, or every optional-mod combination.
## Automated evidence
| Area | Focused check | Candidate result |
| --- | --- | --- |
| Dedicated server, core linking, book/Age identity, old writing and workstation formats | `./gradlew --offline runGameTestServer`; includes `CoreGameTests`, `AgePersistenceGameTests`, `CrystalPortalGameTests`, M7 crafting/advancement checks | 91/91 required GameTests passed on NeoForge 21.1.264; clean shutdown and fresh-world removal. |
| Deterministic structures and terrain | `./gradlew --offline runGameTestServer -ParchivedGameTestSuites=generation`; includes library template/spacing/loot/stand checks, cave profiles, vanilla structures, crystal formations, fissures | 129/129 passed after correcting one village test to assert its own contribution rather than a seed-dependent total; fresh-world removal. |
| Sky, glyph numbers, seasons, creative/JEI page feed | `./gradlew --offline runGameTestServer -ParchivedGameTestSuites=environment` | 103/103 passed on the 0.6.11.0 candidate, including issue #94's number-page check; fresh-world removal. |
| Packaged models and data | `Milestone7ResourceGameTests.coreModelsResolveBundledTextures` in the core suite; parses critical block models, checks their Rivencraft texture references and the library NBT resource | Passed in the 91-test core run. This checks resource presence, not visual orientation or transparency. |
| Recipe behavior | `Milestone7CraftingGameTests` and `LinkingBookRecipeGameTests` through the loaded Recipe Manager | Passed in the core run; confirms crystal and receptacle quantities, ink bucket return, no Blank Page crafting, and Linking Book ingredients. |
| Portal state/save-load | `CrystalPortalGameTests` plus `AgePersistenceGameTests` in the core suite | Passed; covers activation/release, fallback orientation, horizontal/underside placement, uninterrupted surface, cooldown, retired Age and seed/plan identity. Full server restart remains manual. |
The generation archive initially failed
`AgeStructureGameTests.mixedEndAndTaigaVillagesChargeForEndPortion` because it
compared the total instability score (1200) with the village-only expectation
(200). A seed-completed sky legitimately supplied an extra sun (+1000). The
production scoring was unchanged; the check now isolates the
`forced_biome_villages` contribution. The rerun passed 129/129. This is a
repaired assertion, not a hidden feature failure.
Every default GameTest run creates a new world with Minecraft's fixed seed 0
and deletes it after pass or fail. See [GameTest guidance](GAMETESTS.md).
Do not retry a storage-stalled GameTest: stop it, remove that automated
scenario, and give its exact steps to the player. No run in this issue has
stalled so far. The older full-archive storage stall is historical and is not
counted as a current pass. On the subsequent **1.0.0.0 release candidate**,
the environment archive stalled during its first 50-test batch and was
stopped. The 103/103 result above applies to 0.6.11.0 only, not to 1.0.0.0
or the later 1.0.0.1–1.0.0.2 candidates. For the 1.0.0.2 manual handoff, create an Age
with Number Pages immediately before Celestial Angle, Phase, and Size pages;
confirm each affects the intended body rather than becoming a stray page. Also verify a
fixed-season Age remains fixed across a restart while the Overworld remains
independent. Do not rerun the stalled suite merely to obtain a green count.
## Old-save boundary (#60)
Issue #60 established a policy of preserving old saves where practical. Tests
cover bare-list `OrderedWriting`, page-free books, unknown symbol IDs, older
Age definitions with stable defaults, plan-v3/plan-v4 interpretation, and
workstation inventory migration. A bound book's UUID and its Age's registered
dimension remain stable through ordinary load. This does **not** imply every
pre-public-release world, removed biome mod, missing dimension provider, or
changed datapack can be opened. Back up the full world; the Age registry and
dimension folders are a unit. The creative Link Modifier can intentionally
retire an Age, archiving its data and marking the UUID dead. That is an explicit
administrative boundary, not a silent migration.
## Manual matrix for the player
Use a backup or disposable save. Record the exact mod list and jar version.
These checks are **pending for 0.6.11.0** unless a later note records an actual
pass. Historical passes on earlier slices should not be silently promoted to
this candidate.
| Scenario | What to verify |
| --- | --- |
| Full restart | Create a random and an authored Age, a library with a looted chest/page stand, an active portal, and a bound book. Stop and restart the server; UUID, terrain, loot removal, stand contents, portal activation, and return travel must persist. |
| Two players | Cross the same portal at nearly the same time, return independently, and use separate book previews. Cooldowns, destination positions, dropped-book ownership/retrieval, and preview chunk loaders must not interfere. |
| Cross-dimension travel | Compare the same Descriptive Book by hand, stand, and portal. Check footing/platform rules, arrival coordinates and facing, Disarm, Maintain Momentum, Intra Linking, and Return Link mode. |
| Client visuals/audio | Compare book spreads, glyphs, D'ni numbers, page/desk/binder/mixer models, 16×16 crystal buds and translucent elements, portal frame/surface order, skies/eclipses, page turns, and Legacy link sounds. A server test cannot observe pixels or audio. |
| Optional renderers | With neither Create nor Immersive Portals, confirm Legacy fallback. With Create alone, confirm Ponder snapshots and portal guide. With Immersive Portals, confirm live view, Explore exit, sky/sound isolation, portal travel, and fallback while chunks load. With both, `AUTO` should choose Immersive Portals. |
| Optional world/recipe mods | Test JEI guides/search, TerraBlender with a biome-producing addon (for example Biomes O' Plenty), Serene's six season modes plus Overworld independence/restart, and Ruins template selection/spawn weighting. Repeat startup without optional mods. |
| Migration | Copy a backed-up older development world. Verify books and Notebooks retain their Age/page IDs, a desk and stand keep contents, old Ages load or provide a clear error, and removing a custom symbol retains its unresolved ID. Do not test by modifying the only copy. |
| Binder diagnostics | As a non-OP, verify the Binder still creates books but shows no instability/seeded/status messages. As an OP, run `/rivencraft binder messages enable`, reopen the Binder, and verify those messages appear only for the OP. Disable them and restart the world; the setting should remain disabled. |
Historical evidence: the player approved NeoForge 21.1.264 on singleplayer
and a dedicated server with Immersive Portals; JEI 19.69.0.457 guides, Serene
Seasons 10.1.0.9's full per-Age matrix, Ruins generation, and issue #94's
numbered celestials each received an in-game pass on their respective earlier
release slices. See [compatibility review](MILESTONE_7_COMPATIBILITY_REVIEW.md),
[JEI review](MILESTONE_7_JEI_REVIEW.md), [Serene review](MILESTONE_7_SERENE_REVIEW.md),
and [Ruins notes](RUINS_COMPATIBILITY.md). The current candidate's broad
multiplayer/restart/optional-mod matrix remains unverified until tested.
+42
View File
@@ -0,0 +1,42 @@
# Rivencraft 1.0.1.2-alpha.1 — Minecraft 26.3
Experimental **NeoForge 26.3** port preview. This is a separate artifact from
the supported Minecraft 1.21.1 release, and it does **not** have feature
parity. Use a disposable test world or make a backup first. Do not migrate a
1.21.1 Rivencraft world to this alpha.
## Working in this alpha
- Rivencraft loads on Minecraft 26.3 / NeoForge 26.3.0.99 with Java 25.
- The first-party runtime Age backend creates separately seeded, persistent
prototype dimensions. The operator-only `/rivencraft_port age` commands can
create and inspect them.
- Runtime Age catalogs are synchronized and acknowledged before cross-dimension
travel. A missing or unacknowledged destination is refused.
- Operator-created Descriptive Books can enter prototype Ages.
- Handheld Linking Books can be named and bound to a location, then used from
their book GUI to return there. The destination data survives a restart.
- The Crystal Block and Crystal Shard are registered with reused art.
## Not yet ported
Normal authored Age generation, book crafting and workstations, Page symbols,
most blocks and items, portal systems, Link modifiers, the rotating destination
preview, instability, and optional mod integrations. The Linking Book GUI shows
a placeholder destination panel rather than a live scene. Generated Ages use
prototype vanilla terrain, not the player's authored Pages.
The [26.3 port status](MINECRAFT_26_3_PORT.md) and [open port issues](https://git.paddedcell.org/dnorberg/rivencraft/issues)
track the remaining work. The stable 1.21.1 release remains available
separately. Please file alpha bugs with the exact Minecraft, NeoForge and
Rivencraft versions, plus the relevant client/server log.
## Verification
- Clean 26.3 Gradle build passed.
- Disposable dedicated-server smoke test passed: two distinct Age seeds and
their terrain persisted across a restart.
- The user verified the handheld Linking Book GUI click and travel in-game on
2026-10-27.
These checks are **not** the full port acceptance matrix or a stability claim.
+37
View File
@@ -0,0 +1,37 @@
# Rivencraft 1.0.1.3-alpha.1 — Minecraft 26.3
Experimental **NeoForge 26.3** port preview. This is separate from the
supported Minecraft 1.21.1 release and does **not** have feature parity. Use
a disposable test world or make a backup first. Do not migrate a 1.21.1
Rivencraft world to this alpha.
## Changes since 1.0.1.2-alpha.1
- An unbound Descriptive Book now opens a naming screen. Binding it creates a
distinct prototype Age with a random seed; duplicate names receive a
numbered suffix.
- A bound Descriptive Book opens a book screen whose destination panel can be
clicked to enter its Age. The server validates the held book and waits for
the client's Age catalog acknowledgement before travel.
- Retired Age IDs are excluded when assigning a new book's Age name, so an
archived world cannot be silently reused.
## Still experimental
These Ages use prototype vanilla terrain. Page-authored generation, crafting
and workstations, most items and blocks, portals, Link modifiers, instability,
live destination previews, and optional mod integrations are not ported. The
book panel is a placeholder, not a rendered destination view. The
[port status](MINECRAFT_26_3_PORT.md) and [open port issues](https://git.paddedcell.org/dnorberg/rivencraft/issues)
track the remaining work. The stable 1.21.1 release remains available
separately.
## Verification
- Clean 26.3 Gradle build passed.
- Disposable dedicated-server smoke test passed: two distinct Age seeds and
their terrain persisted across restart.
- The player verified Descriptive Book binding and travel in game on
2026-10-29.
These checks are **not** the full port acceptance matrix or a stability claim.
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -1,5 +1,9 @@
# Porting roadmap
For a concise current status, start with [current implementation](CURRENT_IMPLEMENTATION.md)
and [milestone summaries](MILESTONES.md). This file retains the original
roadmap and architectural rationale.
Rivencraft ports Mystcraft Legacy from Minecraft 1.12.2 to NeoForge 1.21.1.
The world-generation, dimension, registry, networking, and rendering APIs have
changed substantially, so the work is divided into independently testable
@@ -62,8 +66,9 @@ acceptance issue and all required implementation issues are complete.
**Complete.** See the [Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md)
and [Milestone 6 acceptance](MILESTONE_6_ACCEPTANCE.md).
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
sounds, advancements, compatibility, and migration tests. Start with the
[Milestone 7 Legacy audit](LEGACY_M7_AUDIT.md).
sounds, advancements, compatibility, and migration tests. **In progress.**
See the [Milestone 7 regression matrix](MILESTONE_7_REGRESSION.md) and
[Legacy audit](LEGACY_M7_AUDIT.md).
## Architectural direction
+5 -2
View File
@@ -66,8 +66,11 @@ unstable in a particular graphics or mod configuration.
For preview-related releases:
1. Run `./gradlew runGameTestServer -PwithArchivedGameTests=true` to verify the complete selection matrix,
core Age creation, persistence, and travel without optional renderers.
1. Run `./gradlew runGameTestServer` for the core selection matrix, Age
creation, persistence, and travel without optional renderers. Add the
relevant archives from [GameTest guidance](GAMETESTS.md); if a scenario
stalls, stop it and hand that exact check to a real client rather than
treating the interrupted run as a pass.
2. Open held Linking and Descriptive Books once with each available backend.
3. Open both book types from a Book Stand and confirm Explore is disabled.
4. With Immersive Portals, enter and leave Explore, travel between dimensions,
+82
View File
@@ -0,0 +1,82 @@
# Publishing Rivencraft with the Gitea, Modrinth, and CurseForge APIs
The local helper is [`scripts/publish_mod_sites.py`](../scripts/publish_mod_sites.py).
It previews a release unless `--publish` is explicitly supplied. The current
configuration is for the Minecraft **26.3** artifact only. The 1.21.1 line needs
its required external dependencies mapped on both sites before API publishing
is enabled for it.
## One-time token setup
For Gitea, an existing personal access token is sufficient if it has
`write:repository` scope. That scope covers release creation and attachment
upload; do not grant `all` if a narrower token is available. Create or inspect
tokens under [Gitea user settings → Applications](https://git.paddedcell.org/user/settings/applications).
The helper checks token identity and scope without displaying the secret.
Create a [Modrinth personal access token](https://docs.modrinth.com/api/#authentication)
in account settings with `VERSION_CREATE` (upload) and `USER_READ` (read-only
credential check) scopes. Create a
[CurseForge Upload API token](https://support.curseforge.com/support/solutions/articles/9000197321)
from the author's API Tokens page. Treat both as passwords: never put them in
git, chat, a release note, or a command-line argument.
In a terminal at the repository root, run these one at a time and paste the
corresponding token into the hidden prompt:
```sh
python3 scripts/publish_mod_sites.py store-token modrinth
python3 scripts/publish_mod_sites.py store-token curseforge
python3 scripts/publish_mod_sites.py store-token gitea
```
The tokens are stored in the desktop Secret Service keyring under
`application=rivencraft-publishing`, separately by site. The script also accepts
`RIVENCRAFT_MODRINTH_TOKEN` or `RIVENCRAFT_CURSEFORGE_TOKEN` environment variables,
but these take precedence over keyring entries and should not be put in shell
history or startup files.
Verify the credentials without uploading anything:
```sh
python3 scripts/publish_mod_sites.py check-token modrinth
python3 scripts/publish_mod_sites.py check-token curseforge
python3 scripts/publish_mod_sites.py check-token gitea
```
## Release workflow
Build the JAR, write a Markdown changelog, and preview the metadata:
```sh
python3 scripts/publish_mod_sites.py release \
--jar ports/mc263/build/libs/rivencraft-26.3-1.0.1.32-beta.1.jar \
--changelog ports/mc263/RELEASE_NOTES_1.0.1.32-beta.1.md \
--version 1.0.1.32-beta.1 \
--name 'Rivencraft 1.0.1.32-beta.1 — NeoForge 26.3' \
--minecraft 26.3 --java 25 --channel beta
```
For a *new* version only, append `--publish` to upload to both sites. Use
`--site modrinth` or `--site curseforge` to target one site. Modrinth rejects an
existing version number; CurseForge may not, so check its file list before any
retry after a partial failure. CurseForge approval may take additional time.
The default destination is now all three sites. The Gitea release step requires
the annotated version tag to have been pushed first. Gitea's `Vx.y.z.b-mc26.3-beta.1`
tag convention is derived from the version and channel; an existing release
with different title or notes is never overwritten. If release creation
succeeds but its JAR upload fails, rerun with `--site gitea --publish` to resume
only the missing attachment. `--site both` retains the earlier Modrinth and
CurseForge-only behavior.
The helper uses Gitea's documented
[`POST /repos/{owner}/{repo}/releases`](https://docs.gitea.com/api/operations/repo-create-release/)
and [release attachment endpoint](https://docs.gitea.com/api/operations/repo-create-release-attachment/),
plus Modrinth's documented multipart
[`POST /version`](https://docs.modrinth.com/api/operations/createversion/) and
CurseForge's documented
[`POST /api/projects/{projectId}/upload-file`](https://support.curseforge.com/support/solutions/articles/9000197321)
with the JAR, changelog, version channel, NeoForge, Minecraft version, Java
version, and client/server environment. It sets no external dependencies for
26.3, which uses Rivencraft's first-party Age backend.
+21
View File
@@ -0,0 +1,21 @@
# Documentation map
Start with the player-facing [project README](../README.md). The two maintained
technical entry points are [current implementation](CURRENT_IMPLEMENTATION.md)
and [milestone summaries](MILESTONES.md). Use [GameTest guidance](GAMETESTS.md)
to run checks. The files below retain detailed design, provenance, and
version-specific test evidence; an older milestone record may describe a
former behavior or loader version.
| Milestone | Detailed records |
| --- | --- |
| M1–M3 | [Porting roadmap](PORTING.md), [Age administration](AGES.md), [Age profiles](AGE_PROFILES.md), [preview compatibility](PREVIEW_COMPATIBILITY.md), [random Age weights](RANDOM_AGE_WEIGHTS.md) |
| M4 | [Writing data](WRITING_DATA.md), [book presentation](DESCRIPTIVE_BOOK_PRESENTATION.md), [Ink Mixer audit](INK_MIXER_MODIFIER_AUDIT.md) |
| M5 | [Acceptance](MILESTONE_5_ACCEPTANCE.md), [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md), [writing grammar](WRITING_GRAMMAR.md), [symbol semantics](SYMBOL_SEMANTICS.md), [TerraBlender](TERRABLENDER_COMPATIBILITY.md), [Serene Seasons](SERENE_SEASONS_COMPATIBILITY.md) |
| M6 | [Acceptance](MILESTONE_6_ACCEPTANCE.md), [regression](MILESTONE_6_REGRESSION.md), [Legacy audit](LEGACY_INSTABILITY_AUDIT.md), [score](INSTABILITY_SCORE.md), [live blocks](LIVE_BLOCK_INSTABILITY.md), [runtime](INSTABILITY_RUNTIME.md), [diagnostics](INSTABILITY_DIAGNOSTICS.md), [status effects](INSTABILITY_STATUS_EFFECTS.md), [decay](INSTABILITY_DECAY.md), [environmental hazards](INSTABILITY_ENVIRONMENTAL.md), [cloud platform](AGE_CLOUD_PLATFORM.md) |
| M7 | [Acceptance](MILESTONE_7_ACCEPTANCE.md), [regression and manual matrix](MILESTONE_7_REGRESSION.md), [Legacy audit](LEGACY_M7_AUDIT.md), [visual/audio](MILESTONE_7_VISUAL_AUDIT.md), [advancements](MILESTONE_7_ADVANCEMENTS.md), [JEI](MILESTONE_7_JEI_REVIEW.md), [Serene review](MILESTONE_7_SERENE_REVIEW.md), [compatibility](MILESTONE_7_COMPATIBILITY_REVIEW.md), [Ruins](RUINS_COMPATIBILITY.md) |
[Asset provenance](ASSET_PROVENANCE.md) applies across milestones. Detailed
audits are intentionally retained for source attribution and migration
history; these three entry points replace the former practice of treating
every dated task note as a current installation guide.
+4 -3
View File
@@ -32,9 +32,10 @@ The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Le
| `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. |
| `season` | `no_seasons`, `seasons`, `eternal_winter`, `eternal_summer`, `eternal_fall`, or `eternal_spring`. Seasonal effects other than No Seasons require optional Serene Seasons. |
| `celestial` | `normal_time` (the first representable single-cycle behavior). |
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. |
| `angle_modifier` | JSON number in `[0, 360)` degrees. |
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler will convert to turns. |
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. An immediately preceding D'ni number page overrides it with digit × 0.08. |
| `angle_modifier` | JSON number in `[0, 360)` degrees. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler converts it to turns. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
| `size_modifier` | JSON scale in `[0.25, 3]`. An immediately preceding D'ni number page overrides it with a linear 0.25×–3× scale: digit 0 is 0.25× and digit 24 is 3×. Without a number, Small/Normal/Large remain 0.5×/1×/2×. |
| `block` | Canonical namespaced material ID for a compatible following feature; registered full-collision blocks and source fluids receive generated Pages. |
| `feature` | Canonical namespaced feature-effect ID; the compiler must recognize and validate it. This includes supported decorations and structures. |
+1 -1
View File
@@ -36,7 +36,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.6.9.3
mod_version=1.0.1.1
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=pHEgQQUE
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,85 @@
# Rivencraft 26.3 beta 1.0.1.106-beta.1
Collects the player-validated port slices .99–.106 since 1.0.1.98-beta.1.
This beta is not yet feature-complete with Minecraft 1.21.1.
## Books and travel
- Fallback Age platforms now use the dedicated non-collectible platform block
from 1.21.1: opaque crystal texture, glass-like breaking strength, no drops
or pick-block item. Existing ordinary Crystal Block platforms are unchanged.
- Ported automatic Return Links for successful handheld Descriptive Book
travel. `descriptive_books.return_link_mode` in `config/rivencraft-local.toml`
defaults to **DISABLED**. `ON_CREATION` issues one on the first arrival per
Age/world with that mode enabled; `ALWAYS` issues one on each arrival.
- Return Links preserve departure dimension, position and facing. They are
issued after Disarm and drop at the destination if inventory is full.
First-arrival records persist across reload, reseeding and restart.
- Successful handheld Linking and Descriptive Book travel now leaves the
active book at departure as a recoverable open-book entity. Both hands are
supported. Failed travel or canceled entity spawning restores the book.
- Dropped books reuse the existing textures and skyward-facing model. They
have a one-second pickup delay, no ordinary five-minute expiry, slow decay
and water/rain damage, matching 1.21.1. Their components and decay counter
persist through restart.
## Page collections and binding
- Ported Notebooks and Collation Folders with their existing editor and assets,
capacities of 252 and 27 positions, sparse page storage, search, scrolling,
page movement and inventory transfers. Link Panels are rejected by the editor.
- Notebook crafting uses the existing leather/string pattern. The held
collection is locked against ordinary menu movement. Escape closes the menu
with search focused; search still consumes inventory/hotbar shortcuts.
- Corrected the port's keyboard and mouse-button mappings, including arrow
movement, right-click handling and scrollbar dragging.
- The Book Binder accepts collection imports: shift-click to append pages or
click a carried collection on the strip to insert them. Empty positions are
skipped and occupied page order is retained. Imports validate all inputs and
capacity before committing; successful imports return the empty collection.
- Added category Symbol Portfolios to Creative, including registry-derived
biome, material and fluid pages. Large categories split into volumes of up
to 252 pages. Portfolios use the editor and Binder import flow.
- Sealed Notebooks open into Folders containing 16 random pages using the
1.21.1 pool/exclusions. Survival consumes one; Creative retains the item.
Full inventories receive a dropped Folder. No new recipes are invented for
Folders, Portfolios or Sealed Notebooks; discovery/library loot is pending.
## Writing Desk
- Ported the two-block Writing Desk, its existing model/textures, selected-page
and white paper-stack display, and Legacy GUI atlases.
- Ported 88 tabs cycled four at a time, category/search/sort/ALL views, collection
and loose-page storage, page movement/extraction, and server-owned catalog
synchronization. Link Panels cannot be stored or copied in the desk.
- Copy previews are virtual. Taking a copy consumes one paper and one ink by
default while retaining the original page. `writing.symbol_ink_cost` is
configurable in `rivencraft-local.toml`.
- Vials fill a 16-unit meter and return vanilla glass bottles. Refills are
event-driven and require bottle space. Full inventory prevents shift-copy
consumption. Unknown pages are retained but cannot be copied unresolved.
- Ported crafting and adapted its recipe-unlock advancement to 26.3.
Removing either desk half removes its partner and drops contents once.
- Further corrections to the desk model, focused on page/paper placement,
remain tracked for both versions in issue #113.
## Compatibility and validation
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
Dimensions and Bad Packets are not required. The 1.21.1 implementation is
unchanged. Use matching client/server builds; Age catalog protocol remains 10.
- Player in-game checks passed. Both build targets passed. Targeted disposable
server fixtures covered ownership recovery, collection/copy transactions,
save/network codecs, block removal, reload/reseed and restart persistence.
Servers were stopped and test worlds removed. This was not a full GameTest run.
- Full writing recovery, remaining discovery/stations, instability, crystal
portals, progression awards and optional integration parity remain unfinished.
JEI guides and discovery loot for the newly ported interactions are pending.
Unsupported effects remain marked red; Creative availability is not parity.
- Back up saves. New Native Cave profiles from .97 onward cannot be read by
earlier betas, and newly ported items/entities also require the newer build.
Keep 1.21.1 and 26.3 saves separate. Machine acceleration (#112) and Native
Cave default biome-pool policy (#111) remain separate planned work.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,19 @@
# Rivencraft 26.3 beta 1.0.1.32-beta.1
This is the first 26.3 beta, focused on authored Age generation. It is not yet
feature-complete with the separate Minecraft 1.21.1 release.
- Ported the Book Binder's ordered symbol-page strip and Legacy-inspired GUI,
plus runtime symbol artwork, creative pages, and registry-derived biome pages.
- Ported the Ink Mixer, its additive modifiers, and one-paper/one-ink-unit Link
Panel creation.
- Added authored Standard, Amplified, and Island terrain; fixed, Tiled, Grid,
Native, and Tiny through Huge biome distributions; and persistent Age plans.
- Fixed workstation block-face occlusion beside the Book Binder and Ink Mixer.
Feature/material writing, Cave/Flat/Void terrain, portals, and other 1.21.1
systems are still being ported. Use a test world and keep a backup. Minecraft
1.21.1 saves are not supported by this beta.
Requires Minecraft 26.3, NeoForge 26.3.0.99 or a compatible 26.3 build, and
Java 25.
@@ -0,0 +1,12 @@
# Rivencraft 26.3 beta 1.0.1.36-beta.1
This beta adds Flat and Void terrain to authored Ages on Minecraft 26.3.
- Bind Descriptive Books with Flat or Void terrain, including multiple Biome pages and supported biome-distribution pages.
- Flat ground starts at Y=0; travel lands on its surface rather than on an elevated fallback platform.
- Flat Ages generate the selected biomes' surface decorations, including trees and vegetation. Void Ages remain empty while retaining authored biome climate.
- Expanded dedicated-server checks for Flat and Void terrain, biome distribution, and persistence after restart.
This 26.3 port is still experimental and does not yet include all features of the separate Minecraft 1.21.1 version. Use a test world and keep a backup. Minecraft 1.21.1 saves are not supported by this beta.
Requires Minecraft 26.3, NeoForge 26.3.0.99 or a compatible 26.3 build, and Java 25.
@@ -0,0 +1,14 @@
# Rivencraft 26.3 beta 1.0.1.39-beta.1
This beta adds Cave terrain to authored Ages on Minecraft 26.3.
- Bind Descriptive Books with the Cave terrain page and Cave or Nether biome pages, including supported multi-biome distributions.
- Cave Ages use the cave noise shape but remain dry by default. Newly generated chunks no longer flood below the vanilla cave sea level.
- Cave travel searches from Y=64 downward for dry, properly footed space instead of landing on the cave roof or underwater.
- Expanded dedicated-server checks for Cave creation, biome distribution, and persistence after restart.
The player verified the Cave and safe-landing changes in game. Existing generated chunks are not drained or regenerated. A future Flooded page will provide an authored water level; see [issue #107](https://git.paddedcell.org/dnorberg/rivencraft/issues/107).
This 26.3 port is still experimental and does not yet include all features of the separate Minecraft 1.21.1 version. Use a test world and keep a backup. Minecraft 1.21.1 saves are not supported by this beta.
Requires Minecraft 26.3, NeoForge 26.3.0.99 or a compatible 26.3 build, and Java 25.
@@ -0,0 +1,13 @@
# Rivencraft 26.3 beta 1.0.1.41-beta.1
This beta expands Age generation on Minecraft 26.3.
- Descriptive Books with no authored symbol pages now choose a seeded random terrain and biome distribution. The chosen plan is saved with the Age and restored after restart.
- A Block material page followed by a Rocks feature page now generates small clusters of that block in newly generated surface chunks. This first feature path supports Standard, Amplified, Island, and Flat terrain; Cave and Void combinations remain unsupported.
- Expanded dedicated-server checks cover random Age plans and authored Rocks generation across restart.
The player verified the Rocks feature in game. Random feature selection, duplicate feature pages, unpaired materials, and instability scoring are not yet ported. Existing chunks are not regenerated.
This 26.3 port is still experimental and does not yet include all features of the separate Minecraft 1.21.1 version. Use a test world and keep a backup. Minecraft 1.21.1 saves are not supported by this beta.
Requires Minecraft 26.3, NeoForge 26.3.0.99 or a compatible 26.3 build, and Java 25.
@@ -0,0 +1,51 @@
# Rivencraft 26.3 beta 1.0.1.54-beta.1
This beta collects the player-validated Age feature port slices since
1.0.1.41-beta.1. It is not yet feature-complete with Minecraft 1.21.1.
## Age generation
- Rocks now use the established 1.21.1 forest-rock placement. Unmodified
material-bearing features choose seeded materials independently; a preceding
compatible Block or Fluid page selects the material explicitly.
- Added Flowers, Trees, Huge Trees, Dungeons, Surface Lakes, Deep Lakes, and
Small Lakes using the 1.21.1 placement rules adapted to 26.3.
- Added Floating Islands, Tendrils, Skylands, Spheres, Spikes, and Obelisks
using the existing Rivencraft generation algorithms.
- Added blue Crystal Formations with budding crystal and initially seeded buds.
- Added Dense Ores: repeated pages replay eligible biome ore placements,
including compatible modded ores and explicitly tagged custom placements.
- Added Star Fissures: one Legacy-shaped rift near the first arrival point,
a starfield at the dimension's minimum Y, and return travel to the shared
Overworld spawn with the original fissure sound. Cave roofs are preserved.
- Feature plans, materials, repeated occurrences, and fissure completion
persist across restart. Existing generated chunks are not redecorated.
## Current limits
- Authored features retain terrain restrictions. Most repeated features allow
up to 32 occurrences; exceeding the current authoring cap blocks assembly.
Skylands and Star Fissures retain their single-effect behavior when repeated.
- Put a material page immediately before a compatible feature. Dangling
material assignment, full Legacy grammar recovery, instability, and random
feature selection are not yet ported. Pageless books randomize terrain and
biome distribution, not the newly authored feature list.
- 26.3 combines base terrain, surface, and carving. Added solid volumes run
after that combined operation and before decoration, so base carvers do not
cut those added volumes.
- The full 1.21.1 writing/discovery loop, structures, environmental systems,
crystal portals, and optional integration parity remain in progress. The
presence of a Creative page is not a promise that its effect is ported.
Build and static checks passed. The player reported in-game passes for these
slices. Earlier server smoke coverage was expanded, but the later generation
slices were manually validated; this release does not claim a new full
dedicated-server regression pass.
Requires Minecraft **26.3**, NeoForge **26.3.0.99** or a compatible 26.3 build,
and **Java 25**. Dynamic Dimensions and Bad Packets are not required by this
26.3 build. Do not use Minecraft 1.21.1 dependencies or saves with it. Back up
the entire world before updating; beta-save compatibility is not guaranteed.
Report issues and help with documentation at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,72 @@
# Rivencraft 26.3 beta 1.0.1.65-beta.1
This beta collects the player-tested port slices since 1.0.1.54-beta.1.
It is not yet feature-complete with the Minecraft 1.21.1 release.
## Age generation
- Added authored structure pages: Villages, Mineshafts, Strongholds, Trial
Chambers, Buried Treasure, Shipwrecks, Ocean Ruins, Pillager Outposts,
Ruined Portals, Nether Fossils, Ancient Cities, Trail Ruins, Nether
Fortresses, Bastions, and 26.3's Abandoned Camps. Dungeons remain their
separate feature.
- Fixed the early structure-biome check so authored structures can generate
outside their native biome restrictions, within the supported terrain rules.
Repeated structure pages increase density up to the current authoring cap.
- Added Caves and Ravines feature pages through 26.3's native carving stage.
These are distinct from Cave terrain; Flat/Void support remains pending.
- Added Poplar Trees, Fallen Poplar Trees, Red Shrubs, and Shelf Mushrooms.
Shelf Mushrooms find exposed logs at any height, including log Tendrils in
Void terrain. No Trees page is required.
- Added Normal, Fast, Slow, Off, Always, and Storm weather pages. Each Age
has independent saved weather data and timers; its policy does not change
the Overworld or other Ages. Cave terrain keeps weather disabled.
## Fixes and feedback
- Non-Cave Descriptive Book arrivals trace actual blocks down from maximum
build height instead of using a Flat-layer shortcut or only a heightmap.
The search stops at roofs/fluids rather than selecting underground pockets.
Leaves count as footing; submerged footing is rejected. Fallback platforms
sit above obstructions, with floor Y=63 in an empty Void.
- Stored surface-Age Star Fissure arrivals are rechecked from above. Cave
terrain still searches down from Y=64, and Linking Books retain exact
bound destinations.
- Restored Legacy linking sounds and successful-link cloud bursts. Disarm
and Intra-Linking use their respective sounds. Star Fissure return audio
also plays after arrival so a dimension switch does not discard it.
- Fixed weather-only authoring, including Link Panel + Storm books: non-biome
pages no longer get interpreted as biome IDs.
- Unsupported pages now have a red tint and availability tooltip. Supported
pages and their descriptions reflect the implemented 26.3 behavior.
- Added operator weather and surface diagnostics for testing.
## Current limits
- Only the supported page/terrain combinations can be bound. Most repeated
features are capped at 32; vegetation uses a combined 32-page limit. The
full 1.21.1 grammar recovery and instability systems remain unported.
- Pageless books currently randomize terrain and biome distribution, not the
complete feature/environment list. Omitted weather currently means Normal.
- Base carvers do not carve authored solid volumes added after 26.3's combined
terrain operation. Previously generated chunks are not regenerated.
- The full writing/discovery loop, celestial/lighting systems, crystal portals,
instability, and optional-integration parity remain in progress. Creative
availability alone does not mean a page is supported; check its tooltip.
- Vanilla `/weather` targets global server weather, not the independent Age
policy. Biomes retain their normal precipitation rules.
The player reported in-game passes for these slices. Build/static checks passed.
Targeted disposable dedicated-server checks covered all six weather policies,
Overworld isolation, unload/reload and restart persistence, weather-only page
compilation, and dry surface/leaf spawn probes. This is not a claim of full
dedicated-server or optional-mod regression coverage. Test servers were stopped
and their temporary worlds removed.
Requires Minecraft **26.3**, NeoForge **26.3.0.99** or a compatible 26.3 build,
and **Java 25**. This build does not require Dynamic Dimensions or Bad Packets.
Keep it separate from the 1.21.1 build and saves. Back up entire worlds before
updating; beta-save compatibility is not guaranteed.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,59 @@
# Rivencraft 26.3 beta 1.0.1.71-beta.1
This beta collects the player-tested lighting and sky port slices since
1.0.1.65-beta.1. It is not yet feature-complete with Minecraft 1.21.1.
## Lighting and seeded environments
- Added Normal, Dark, and Bright lighting pages with saved per-Age settings.
- Bright works in Cave terrain too, without adding skylight or weather.
- Bright supplies effective ambient light level 15 to native combined-light
checks and the white ambient rendering attribute. Vanilla darkness-based
monster checks respond to that lighting; there is no separate mob-spawn
veto. Mobs that ignore light retain their normal rules.
- Missing weather and lighting controllers now resolve deterministically from
the Age seed, using independent choices and Legacy-style Normal weighting.
Explicit pages win; Cave terrain still forces weather Off. Existing Ages
keep their saved choices rather than rerolling on load.
## Sky styles
- Added Overworld, Nether, End, and animated Static Sky pages, independently
of terrain, weather, and lighting. Choices persist on reload and restart.
- Nether Sky uses a reddish fog backdrop; End Sky uses the native End sky.
These are visual styles, not Nether/End terrain or gameplay conversions:
dimension geometry, coordinate scale, bed/water rules, and dragon behavior
are not changed.
- Static Sky adapts the existing Legacy-style renderer: six independent
48×48 animated gray noise faces. A cached cube and small dynamic texture
avoid rebuilding thousands of per-cell vertices each frame. Client-only
rendering resources are released with the sky renderer.
- Non-Overworld backgrounds have transparent clouds and a derived day
timeline that avoids Overworld sky/fog/cloud color tracks while retaining
gameplay and illumination schedules.
- Updated supported-page feedback and added operator sky/environment probes.
## Current limits and testing
- Missing Sky pages still default to Overworld; random sky selection, color
pages and individual celestial bodies remain pending. Number-controlled
Static Sky resolution is tracked separately in issue #109 for both versions.
- The writing/discovery loop, instability, portals, and optional-integration
parity are still incomplete. Creative availability is not a support claim;
check page tooltips and the beta guide for supported combinations.
- Previously generated chunks are not regenerated. Save compatibility during
the beta is not guaranteed; keep backups and keep both version tracks apart.
The player reported in-game passes for the included slices, including Static
Sky. Build checks passed for both version tracks. Targeted disposable-server
checks covered environment defaults, explicit choices, registered sky/lighting
variants, persistence and restart, including Cave + Bright and Static Sky.
This is not a claim of complete dedicated-server or optional-mod coverage.
Test servers were stopped and temporary worlds removed.
Requires Minecraft **26.3**, NeoForge **26.3.0.99** and **Java 25**. Dynamic
Dimensions and Bad Packets are not required by this build. The 1.21.1 release
and its dependencies are unchanged.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,53 @@
# Rivencraft 26.3 beta 1.0.1.78-beta.1
Collects the player-validated port slices since 1.0.1.71-beta.1. This beta is
not yet feature-complete with the Minecraft 1.21.1 version.
## Sky and color writing
- Missing Sky pages now select a deterministic seeded style. Books with
Normal Time and no explicit Sky select celestial-compatible Overworld Sky.
Explicit Sky pages always win; weather and lighting choices stay independent.
- Ported Red, Green, Blue, Black and White color pages. Preceding Number pages
control component strength; unnumbered colors use seeded values. Colors mix
before clamping the completed group. Colors before Sky tint that sky; a
trailing color group also targets the sky.
- Added an Ambient Color controller for an independent ambient-light tint.
Color alone does not change effective illumination or monster-spawn rules.
- Native, End and animated Static backgrounds use saved Age color settings.
## Normal Time celestial controls
- Length pages control the native day cycle; a preceding Number fine-tunes
length. Zero Length freezes the cycle at a seeded time or an authored Phase,
without interpolation bounce or changing the shared Overworld clock.
- Rising, Zenith, Setting and Nadir Phase pages choose the starting phase.
Clockwise and Counterclockwise control direction. North, East, South and
West Angle pages choose orbit bearing. Preceding Number pages fine-tune
Phase and Angle in 15-degree increments.
- Small, Normal and Large Celestial size Normal Time's sun to 0.5×, 1× and 2×.
A preceding Number overrides size from 0.25× to 3×. As in 1.21.1, the paired
moon remains normal-sized. Size does not alter illumination or day length.
- Settings persist through reload/restart and remain stable when reseeding
future terrain. Older profiles retain native/default values.
- Added operator diagnostics for environment, color and celestial profiles.
## Compatibility and testing
- Catalog protocol is now 6: use matching client/server builds.
- Player in-game checks passed for the included slices. Build checks and
targeted disposable-server diagnostics covered numeric modifiers, catalog
round trips, unchanged sampled lighting, reload, reseed and restart.
- Standalone Sun/Moon controllers, multiple bodies, per-body colors, eclipses,
full writing/discovery, instability, portals and optional-integration parity
remain incomplete. Creative availability does not imply full page support.
- Number-controlled Static Sky resolution remains tracked in issue #109;
time-page naming improvements remain tracked in issue #110.
- Existing chunks are not regenerated. Save compatibility is not guaranteed
during beta; keep backups and keep the two Minecraft version tracks apart.
Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
Dimensions and Bad Packets are not required. Minecraft 1.21.1 is unchanged.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,83 @@
# Rivencraft 26.3 beta 1.0.1.93-beta.1
Collects the player-validated port slices .79–.93 since 1.0.1.78-beta.1.
This beta is not yet feature-complete with Minecraft 1.21.1.
## Celestial writing and rendering
- Ported standalone Sun and Moon pages and multiple independently authored
bodies, up to ten of each. Each controller consumes its own ordered Color,
Length, Phase, Direction, Angle and Size modifiers; Number pages fine-tune
supported values. Normal Time retains its paired sun/moon behavior.
- Color groups before Normal Time tint both bodies; independent Sun/Moon
groups can use different colors. Authored moons use full-moon sprites as in
1.21.1; native Normal Time retains calendar moon phases.
- Authored bodies render over Overworld, Nether, End and animated Static
backgrounds. Opaque centers retain transparent glows. Eclipse alignment
affects body color, background shading and gameplay sky light.
- Bare Sun/Moon pages receive independent seeded colors, sizes, bearings,
phases, directions and lengths, including stopped cycles. Explicit modifier
groups retain their authored behavior.
- Books without celestial controllers seed-complete 0–3 suns and 0–3 moons.
Empty skies do not accidentally draw a vanilla pair. Explicit controllers
suppress extra-body completion. Link-Panel-only random Ages also receive
seeded sky/fog colors using the 1.21.1 palette.
- All resolved settings are saved, not rerolled by viewing a book, restarting,
or reseeding future terrain. Existing Age profiles remain unchanged.
## Biome and feature completion
- Explicit biome-distribution pages can now seed-complete missing biome
pools. Single selects one; other nonnative controllers require two distinct
biomes. Fully omitted pools use the seeded continuation rule, bounded by the
available biome pool. Authored pools fill only the missing minimum.
- Supplied biome order and duplicate weights are retained. Two distinct
supplied biomes do not acquire an unwanted third biome. Cave pools use
recognized Cave/Nether biomes; Void does not inject The Void alongside
supplied biome pages.
- Unpaired Block/Fluid pages receive seeded terrain-compatible implied
features. Consecutive materials resolve independently; explicit pairs remain
authoritative. Unsupported pairings and the current feature caps remain.
- Link-Panel-only Ages roll 0–2 distinct material feature types with Legacy
rank weighting and compatible registered solids/source fluids. Additional
rare rolls cover Skylands, Crystal Formations and Star Fissures.
- Random Ages also roll 0–2 distinct structure families, filtered by terrain,
registry availability and the chosen biome pool. Newer structures, including
26.3 Abandoned Camps, are rare candidates. An intentionally empty selection
disables page-controlled structure sets rather than enabling all of them.
- Cave terrain now supports authored and randomly selected Dungeons, matching
1.21.1. Dungeon placement retains eight monster-room attempts per page and
modern build-height anchors. Flat/Void Dungeons and Cave lakes remain
unsupported.
## Nether and End material correction
- Ported 1.21.1's biome-specific material-rule layer for noise terrain. Nether
biomes in Cave Ages now use their native Netherrack, Nylium, Soul Sand/Soil,
Basalt and Blackstone rules instead of Overworld grass. End biomes use End
Stone. Other biomes retain the original terrain rules.
- Geometry, biome selection, dry Cave aquifers and other environment settings
are preserved. Flat/Void layers and authored feature materials are unchanged.
- Existing Ages pick up the correction after reload, but only new chunks
change. Already generated grass is not replaced.
## Compatibility and validation
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
Dimensions and Bad Packets are not required. Minecraft 1.21.1 is unchanged.
- Catalog protocol is **10**: use matching client/server builds.
- Player in-game checks passed for all included slices. Builds and targeted
disposable-server checks covered writing, seeded completion, codecs/catalog
round trips, terrain materials, reload, reseed and restart. Temporary servers
and test worlds were removed. This is not a full automated GameTest run.
- Full writing recovery, discovery/stations, instability, portals and optional
integration parity remain unfinished. Random-page eligibility config also
remains pending; valuable/hazardous random materials currently lack the
finished version's instability scoring. Creative availability does not imply
full page support. Number-controlled Static resolution (#109) and time-page
naming (#110) remain separate work.
- Beta save compatibility is not guaranteed. Back up worlds and keep the two
Minecraft version tracks and saves separate.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,63 @@
# Rivencraft 26.3 beta 1.0.1.98-beta.1
Collects the player-validated port slices .94–.98 since 1.0.1.93-beta.1.
This beta is not yet feature-complete with Minecraft 1.21.1.
## Seeded terrain and biome completion
- Books without a Terrain page now choose terrain using the 1.21.1 weighted
selector. Incompatible choices are replaced with a seeded compatible terrain;
explicit Terrain pages retain their authored behavior.
- Omitted biome controllers now use the seeded weighted distribution selector.
Supplied biome pages are retained, with missing minimum slots completed.
Void without biome pages still uses The Void. Link-Panel-only random Ages
retain their existing selection path.
- Missing nonnative biome pools account for authored structures. Single chooses
a terrain-compatible biome satisfying all structure requirements where one
exists. Multiple-biome distributions add compatible biomes for unmet structure
requirements before completing their ordinary minimum. Explicit Single
choices are never replaced.
- New Native Cave Ages freeze the full recognized Cave/Nether biome pool and
distribute it using seeded Tiny regions, matching the 1.21.1 implementation.
This includes recognized modded cave biomes. Existing empty Native Cave
profiles retain their previous Lush Caves fallback; explicit Single is unchanged.
- Removing Nether-style biomes from the default Native Cave pool is separate
planned work for both versions in issue #111, not part of this release.
## Accelerated pages
- Ported the Accelerated environment page. Repeated pages stack, each adding
three extra random positions per eligible block section per tick near players.
- Defaults match 1.21.1: at most eight already-loaded candidate chunks and
16,384 extra random-tick attempts per Age tick, shared fairly across sections.
No extra chunks are force-loaded.
- Limits are configurable in `config/rivencraft-local.toml`, under
`accelerated_age`. A zero attempt budget disables the extra ticks.
- Page counts persist across unload/reload, reseeding and restart. Existing
Ages default to zero. Pageless random Ages do not gain Accelerated automatically.
- This currently affects random-ticking blocks such as crops and saplings.
Machine/block-entity acceleration is deferred for both versions under issue
#112; entities and scheduled block ticks are also unaffected. Instability
scoring remains part of the unfinished instability port.
- Operator command `/rivencraft_port age acceleration <name>` reports the
saved page count and current budgets.
## Compatibility and validation
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
Dimensions and Bad Packets are not required. Minecraft 1.21.1 is unchanged.
- Catalog protocol remains **10**. Use matching client/server builds.
- Player in-game checks passed. Builds and targeted disposable-server checks
covered seeded completion, Native Cave sampling, tick-budget arithmetic,
unload/reload, reseeding and restart. Test servers were stopped and their
worlds removed. This was not a full automated GameTest run.
- Back up worlds before updating. New populated Native Cave profiles cannot
be read by earlier beta builds; do not downgrade those saves. Keep Minecraft
1.21.1 and 26.3 saves separate.
- Full writing recovery, discovery/stations, instability, crystal portals and
optional integration parity remain unfinished. Random-page eligibility config
and modded biome surface-rule integration also remain pending. Creative page
availability does not imply full support.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
+288
View File
@@ -0,0 +1,288 @@
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
// Keep the bundled page list in step with the 1.21.1 symbol definitions.
abstract class GeneratePortSymbolIndex extends DefaultTask {
@InputDirectory abstract DirectoryProperty getSymbolsDir()
@InputDirectory abstract DirectoryProperty getPortSymbolsDir()
@OutputDirectory abstract DirectoryProperty getIndexDir()
@TaskAction void generate() {
File root = symbolsDir.get().asFile
def definitions = new LinkedHashMap<String, Object>()
java.nio.file.Files.walk(root.toPath()).withCloseable { paths ->
paths.filter { java.nio.file.Files.isRegularFile(it) && it.toString().endsWith('.json') }
.sorted().forEach { source ->
String path = root.toPath().relativize(source).toString()
.replace('\\', '/').replaceAll(/\.json$/, '')
definitions["rivencraft:${path}"] = new groovy.json.JsonSlurper().parse(source.toFile())
}
}
File portRoot = portSymbolsDir.get().asFile
java.nio.file.Files.walk(portRoot.toPath()).withCloseable { paths ->
paths.filter { java.nio.file.Files.isRegularFile(it) && it.toString().endsWith('.json') }
.sorted().forEach { source ->
String path = portRoot.toPath().relativize(source).toString()
.replace('\\', '/').replaceAll(/\.json$/, '')
definitions["rivencraft:${path}"] = new groovy.json.JsonSlurper().parse(source.toFile())
}
}
File index = new File(indexDir.get().asFile, 'assets/rivencraft/creative_symbol_pages.json')
index.parentFile.mkdirs()
index.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(definitions)) + '\n'
}
}
def generatePortSymbolIndex = tasks.register('generatePortSymbolIndex', GeneratePortSymbolIndex) {
symbolsDir.set(layout.projectDirectory.dir('../../src/main/resources/data/rivencraft/rivencraft/symbols'))
portSymbolsDir.set(layout.projectDirectory.dir('src/main/resources/data/rivencraft/rivencraft/symbols'))
indexDir.set(layout.buildDirectory.dir('generated/resources/creativeSymbols'))
}
sourceSets.main.resources.srcDir generatePortSymbolIndex
// Use the established symbol names/descriptions without overwriting 26.3-only strings.
abstract class GeneratePortLanguage extends DefaultTask {
@InputFile abstract RegularFileProperty getBaseLanguage()
@InputFile abstract RegularFileProperty getPortLanguage()
@OutputDirectory abstract DirectoryProperty getOutputDir()
@TaskAction void generate() {
def base = new groovy.json.JsonSlurper().parse(baseLanguage.get().asFile) as Map
def port = new groovy.json.JsonSlurper().parse(portLanguage.get().asFile) as Map
base.putAll(port)
File result = new File(outputDir.get().asFile, 'assets/rivencraft/lang/en_us.json')
result.parentFile.mkdirs()
result.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(base)) + '\n'
}
}
def generatePortLanguage = tasks.register('generatePortLanguage', GeneratePortLanguage) {
baseLanguage.set(layout.projectDirectory.file('../../src/main/resources/assets/rivencraft/lang/en_us.json'))
portLanguage.set(layout.projectDirectory.file('port_en_us.json'))
outputDir.set(layout.buildDirectory.dir('generated/resources/portLanguage'))
}
sourceSets.main.resources.srcDir generatePortLanguage
// Derive registered presentation variants without duplicating vanilla gameplay settings.
abstract class GeneratePortDimensionTypes extends DefaultTask {
@InputFile abstract RegularFileProperty getMinecraftJar()
@OutputDirectory abstract DirectoryProperty getOutputDir()
@TaskAction void generate() {
new java.util.zip.ZipFile(minecraftJar.get().asFile).withCloseable { archive ->
def entry = archive.getEntry('data/minecraft/dimension_type/overworld.json')
if (entry == null) throw new GradleException('Missing vanilla Overworld dimension type')
String template = archive.getInputStream(entry).withCloseable { it.getText('UTF-8') }
def dayEntry = archive.getEntry('data/minecraft/timeline/day.json')
if (dayEntry == null) throw new GradleException('Missing vanilla day timeline')
def day = archive.getInputStream(dayEntry).withCloseable {
new groovy.json.JsonSlurper().parseText(it.getText('UTF-8'))
}
// Keep gameplay schedules and illumination, but do not tint these
// native non-Overworld sky backgrounds blue/black with the day cycle.
['fog_color', 'sky_color', 'cloud_color'].each { day.tracks.remove("minecraft:visual/${it}".toString()) }
// Markers are global per clock and already owned by minecraft:day.
day.remove('time_markers')
File dayFile = new File(outputDir.get().asFile, 'data/rivencraft/timeline/age_sky_day.json')
dayFile.parentFile.mkdirs()
dayFile.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(day)) + '\n'
File timelineTag = new File(outputDir.get().asFile, 'data/rivencraft/tags/timeline/age_sky.json')
timelineTag.parentFile.mkdirs()
timelineTag.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([
values: ['#minecraft:universal', 'rivencraft:age_sky_day',
'minecraft:moon', 'minecraft:early_game']])) + '\n'
['overworld', 'nether', 'end', 'static'].each { sky ->
['age_normal', 'age_dark', 'age_bright', 'age_cave_bright'].each { lighting ->
if (sky == 'overworld' && lighting == 'age_normal') return
String name = lighting + (sky == 'overworld' ? '' : "_sky_${sky}")
def definition = new groovy.json.JsonSlurper().parseText(template)
definition.has_skylight = lighting in ['age_normal', 'age_bright']
definition.ambient_light = lighting in ['age_bright', 'age_cave_bright'] ? 1.0 : 0.0
// 26.3's lightmap reads this attribute, not ambient_light.
if (definition.ambient_light == 1.0)
definition.attributes['minecraft:visual/ambient_light_color'] = '#ffffff'
if (sky != 'overworld') {
definition.skybox = sky == 'end' ? 'end' : 'none'
definition.attributes['minecraft:visual/fog_color'] = sky == 'static' ? '#929292' : sky == 'end' ? '#181318' : '#6b2725'
definition.attributes['minecraft:visual/sky_color'] = sky == 'static' ? '#929292' : sky == 'end' ? '#000000' : '#6b2725'
definition.attributes['minecraft:visual/cloud_color'] = '#00000000'
if (sky == 'nether') {
definition.attributes['minecraft:visual/fog_start_distance'] = 10.0
definition.attributes['minecraft:visual/fog_end_distance'] = 96.0
}
definition.timelines = '#rivencraft:age_sky'
}
File result = new File(outputDir.get().asFile, "data/rivencraft/dimension_type/${name}.json")
result.parentFile.mkdirs()
result.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(definition)) + '\n'
}
}
}
}
}
def generatePortDimensionTypes = tasks.register('generatePortDimensionTypes', GeneratePortDimensionTypes) {
dependsOn 'createMinecraftArtifacts'
minecraftJar.set(layout.buildDirectory.file("moddev/artifacts/minecraft-patched-${neo_version}.jar"))
outputDir.set(layout.buildDirectory.dir('generated/resources/dimensionTypes'))
}
sourceSets.main.resources.srcDir generatePortDimensionTypes
tasks.named('processResources', ProcessResources).configure {
// Reuse the existing sprite and base model without copying binary assets.
from('../../src/main/resources') {
include 'data/rivencraft/worldgen/structure_set/fortresses.json'
include 'data/rivencraft/worldgen/structure_set/bastions.json'
include 'assets/rivencraft/models/block/star_fissure.json'
include 'assets/rivencraft/blockstates/star_fissure.json'
include 'assets/rivencraft/sounds.json'
include 'assets/rivencraft/sounds/**/*.ogg'
include 'assets/rivencraft/textures/item/crystal_shard.png'
include 'assets/rivencraft/textures/item/page.png'
include 'assets/rivencraft/textures/item/page_link_panel.png'
include 'assets/rivencraft/textures/item/page_symbol.png'
include 'assets/rivencraft/models/item/page_symbol.json'
include 'assets/rivencraft/textures/gui/symbolcomponents.png'
include 'assets/rivencraft/models/item/page_link_panel.json'
include 'assets/rivencraft/textures/item/ink_vial.png'
include 'assets/rivencraft/textures/item/notebook.png'
include 'assets/rivencraft/textures/item/collation_folder.png'
include 'assets/rivencraft/models/item/notebook.json'
include 'assets/rivencraft/models/item/collation_folder.json'
include 'assets/rivencraft/textures/item/sealed_notebook.png'
include 'assets/rivencraft/textures/item/symbol_portfolio.png'
include 'assets/rivencraft/models/item/sealed_notebook.json'
include 'assets/rivencraft/models/item/symbol_portfolio.json'
include 'assets/rivencraft/textures/item/writing_desk.png'
include 'assets/rivencraft/textures/block/writing_desk.png'
include 'assets/rivencraft/textures/gui/writing_desk_legacy.png'
include 'assets/rivencraft/textures/gui/scrollbar.png'
include 'assets/rivencraft/models/item/writing_desk.json'
include 'assets/rivencraft/models/block/writing_desk.json'
include 'assets/rivencraft/models/block/writing_desk_extension.json'
include 'assets/rivencraft/blockstates/writing_desk.json'
include 'data/rivencraft/loot_table/blocks/writing_desk.json'
include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta'
include 'assets/rivencraft/models/item/ink_vial.json'
include 'assets/rivencraft/models/item/crystal_shard.json'
include 'assets/rivencraft/textures/item/descriptive_book.png'
include 'assets/rivencraft/models/item/descriptive_book.json'
include 'assets/rivencraft/textures/item/linking_book.png'
include 'assets/rivencraft/textures/item/linking_book_unwritten.png'
include 'assets/rivencraft/models/item/linking_book_bound.json'
include 'assets/rivencraft/textures/gui/bookui_cover.png'
include 'assets/rivencraft/textures/gui/notebook_pages.png'
include 'assets/rivencraft/textures/gui/bookui_pager.png'
include 'assets/rivencraft/textures/block/crystal_block.png'
include 'assets/rivencraft/textures/entity/linking_book.png'
include 'assets/rivencraft/textures/entity/descriptive_book.png'
include 'assets/rivencraft/textures/block/book_binder.png'
include 'assets/rivencraft/textures/block/book_binder_press.png'
include 'assets/rivencraft/textures/gui/book_binder_legacy.png'
include 'assets/rivencraft/textures/gui/ink_mixer_legacy.png'
include 'assets/rivencraft/textures/block/inkwell_base.png'
include 'assets/rivencraft/textures/block/inkwell_cup.png'
include 'assets/rivencraft/textures/block/ink.png'
include 'assets/rivencraft/textures/block/ink_glass.png'
include 'assets/rivencraft/models/block/ink_mixer.json'
include 'assets/rivencraft/models/item/ink_mixer.json'
include 'assets/rivencraft/blockstates/ink_mixer.json'
include 'data/rivencraft/loot_table/blocks/ink_mixer.json'
include 'assets/rivencraft/models/block/book_binder.json'
include 'assets/rivencraft/models/item/book_binder.json'
include 'assets/rivencraft/blockstates/book_binder.json'
include 'data/rivencraft/loot_table/blocks/book_binder.json'
include 'assets/rivencraft/models/block/crystal_block.json'
include 'assets/rivencraft/blockstates/crystal_block.json'
include 'assets/rivencraft/models/block/cloud_platform.json'
include 'assets/rivencraft/blockstates/cloud_platform.json'
include 'assets/rivencraft/blockstates/budding_crystal.json'
include 'assets/rivencraft/blockstates/small_crystal_bud.json'
include 'assets/rivencraft/blockstates/medium_crystal_bud.json'
include 'assets/rivencraft/blockstates/large_crystal_bud.json'
include 'assets/rivencraft/blockstates/crystal_cluster.json'
include 'assets/rivencraft/models/block/budding_crystal.json'
include 'assets/rivencraft/models/block/small_crystal_bud.json'
include 'assets/rivencraft/models/block/medium_crystal_bud.json'
include 'assets/rivencraft/models/block/large_crystal_bud.json'
include 'assets/rivencraft/models/block/crystal_cluster.json'
include 'assets/rivencraft/models/item/budding_crystal.json'
include 'assets/rivencraft/models/item/small_crystal_bud.json'
include 'assets/rivencraft/models/item/medium_crystal_bud.json'
include 'assets/rivencraft/models/item/large_crystal_bud.json'
include 'assets/rivencraft/models/item/crystal_cluster.json'
include 'assets/rivencraft/textures/block/small_crystal_bud.png'
include 'assets/rivencraft/textures/block/medium_crystal_bud.png'
include 'assets/rivencraft/textures/block/large_crystal_bud.png'
include 'assets/rivencraft/textures/block/crystal_cluster.png'
include 'data/rivencraft/loot_table/blocks/crystal_block.json'
include 'data/minecraft/tags/block/mineable/pickaxe.json'
include 'data/minecraft/tags/item/cluster_max_harvestables.json'
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
tasks.named('runServer').configure {
standardInput = System.in
}
+10
View File
@@ -0,0 +1,10 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.caching=true
minecraft_version=26.3
minecraft_version_range=[26.3]
neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.106-beta.1
+110
View File
@@ -0,0 +1,110 @@
{
"item.rivencraft.crystal_shard": "Crystal Shard",
"item.rivencraft.descriptive_book": "Descriptive Book",
"item.rivencraft.linking_book": "Linking Book",
"item.rivencraft.page": "Page",
"item.rivencraft.page.blank": "Blank Page",
"item.rivencraft.page.link_panel": "Link Panel Page",
"item.rivencraft.page.unresolved": "Unknown Symbol Page",
"item.rivencraft.page.symbol.implemented": "Implemented in 26.3; book and terrain requirements still apply.",
"symbol.rivencraft.weather.normal.description": "Use an independent normal weather cycle. Cave terrain keeps weather off.",
"symbol.rivencraft.sky.overworld.description": "Use the native Overworld sky. Terrain and lighting remain independently authored.",
"symbol.rivencraft.sky.nether.description": "Use a Nether-style reddish fog backdrop. Authored celestials render over it; terrain and gameplay rules remain independent.",
"symbol.rivencraft.sky.end.description": "Use the native End sky backdrop with authored celestials over it, without End terrain or a dragon fight.",
"symbol.rivencraft.sky.static.description": "Animated static surrounds the Age, behind authored celestials. Preceding colors tint it; otherwise it is gray.",
"symbol.rivencraft.lighting.ambient_color": "Ambient Light Color",
"symbol.rivencraft.lighting.ambient_color.description": "Apply preceding shared colors to ambient light. Use Bright separately for full effective ambient lighting.",
"symbol.rivencraft.number.description": "Before Color: value × 10 per channel. Before Length: value × 0.08 days. Before Phase/Angle: value × 15 degrees. Before Size: 0.25× to 3× (0-24).",
"symbol.rivencraft.size.small.description": "Before Sun/Moon: 0.5× body size. Before Normal Time: size the sun only. A preceding Number overrides the size.",
"symbol.rivencraft.size.normal.description": "Before Sun/Moon: 1× body size. Before Normal Time: size the sun only. A preceding Number selects 0.25× through 3× (0-24).",
"symbol.rivencraft.size.large.description": "Before Sun/Moon: 2× body size. Before Normal Time: size the sun only. A preceding Number selects 0.25× through 3× (0-24).",
"symbol.rivencraft.angle.north.description": "Before Normal Time, Sun or Moon: set orbit bearing to 0 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.east.description": "Before Normal Time, Sun or Moon: set orbit bearing to 90 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.south.description": "Before Normal Time, Sun or Moon: set orbit bearing to 180 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.west.description": "Before Normal Time, Sun or Moon: set orbit bearing to 270 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.phase.rising.description": "Before a celestial controller: start the sun or standalone moon at rising. A preceding Number overrides Phase in 15-degree steps from Nadir.",
"symbol.rivencraft.phase.zenith.description": "Before a celestial controller: start the sun or standalone moon overhead. Zero Length holds this position.",
"symbol.rivencraft.phase.setting.description": "Before a celestial controller: start the sun or standalone moon at setting. A preceding Number overrides Phase in 15-degree steps from Nadir.",
"symbol.rivencraft.phase.nadir.description": "Before a celestial controller: start the sun or standalone moon below the world. Zero Length holds this position.",
"symbol.rivencraft.direction.clockwise.description": "Before Normal Time, Sun or Moon: advance this Age's celestial cycle in its usual direction.",
"symbol.rivencraft.direction.counterclockwise.description": "Before Normal Time, Sun or Moon: reverse this Age's celestial cycle. Does not change the Overworld clock.",
"symbol.rivencraft.celestial.normal_time.description": "Add a sun/moon pair. Preceding modifiers apply to this pair; Size affects its sun only. Colors tint both. Limit ten suns and ten moons per Age.",
"symbol.rivencraft.celestial.sun.description": "Add a sun. A bare page gets saved seeded color, size and orbit controls. Written modifiers preserve authored behavior and reset per controller. Moon alignment causes eclipses; limit ten suns.",
"symbol.rivencraft.celestial.moon.description": "Add a moon with weaker light. A bare page gets saved seeded color, size and orbit controls. Written modifiers preserve authored behavior and reset per controller. Sun alignment causes eclipses; limit ten moons.",
"symbol.rivencraft.period.zero.description": "Before Normal Time, Sun or Moon: freeze at the written Phase, or a saved seeded time if omitted. A preceding Number overrides the Length.",
"symbol.rivencraft.period.half.description": "Before Normal Time, Sun or Moon: a half-length cycle (twice as fast). A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.period.full.description": "Before Normal Time, Sun or Moon: a normal-length cycle. A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.period.double.description": "Before Normal Time, Sun or Moon: a double-length cycle (half as fast). A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.color.red.description": "Add red to the next Sky, Ambient Light Color or celestial controller; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.green.description": "Add green to the next Sky, Ambient Light Color or celestial controller; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.blue.description": "Add blue to the next Sky, Ambient Light Color or celestial controller; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.white.description": "Add equally to all RGB channels. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.black.description": "Subtract equally from all RGB channels. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.lighting.normal.description": "Use normal skylight and ambient brightness. Cave terrain remains without skylight.",
"symbol.rivencraft.lighting.dark.description": "Remove skylight from this Age; placed light sources still work.",
"symbol.rivencraft.lighting.bright.description": "Provide full ambient brightness and effective light level 15. Vanilla light-based spawn rules apply, including in Cave terrain without skylight.",
"symbol.rivencraft.weather.fast.description": "Use the faster Legacy weather cycle in this Age only. Cave terrain keeps weather off.",
"symbol.rivencraft.weather.slow.description": "Use the slower Legacy weather cycle in this Age only. Cave terrain keeps weather off.",
"symbol.rivencraft.weather.off.description": "Keep this Age clear without changing Overworld weather.",
"symbol.rivencraft.weather.always.description": "Keep this Age rainy, or snowy in cold biomes, without thunder. Cave terrain keeps weather off.",
"symbol.rivencraft.weather.storm.description": "Keep this Age rainy and thundering. Precipitation still depends on the biome; Cave terrain keeps weather off.",
"item.rivencraft.page.symbol.unimplemented": "Not yet implemented in 26.3; cannot be bound into an Age.",
"item.rivencraft.link_property.disarm": "Disarm",
"item.rivencraft.link_property.maintain_momentum": "Maintain Momentum",
"item.rivencraft.link_property.intra_linking": "Intra-Linking",
"item.rivencraft.ink_vial": "Ink Vial",
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
"item.rivencraft.ink_vial.usage": "Use with paper in the other hand, or place in an Ink Mixer.",
"item.rivencraft.page.blank.port_status": "Internal symbol-page base; not an authoring ingredient.",
"item.rivencraft.page.link_panel.port_status": "Use with leather in a Book Binder to make a Descriptive Book.",
"block.rivencraft.book_binder": "Book Binder",
"container.rivencraft.book_binder": "Book Binder",
"screen.rivencraft.book_binder.missing_panel": "Link Panel required",
"screen.rivencraft.book_binder.authored_pending": "Unsupported page or page combination",
"block.rivencraft.ink_mixer": "Ink Mixer",
"container.rivencraft.ink_mixer": "Ink Mixer",
"screen.rivencraft.ink_mixer.ink": "Ink: %s/%s",
"screen.rivencraft.ink_mixer.additive_hint": "Click with an additive to mix it in",
"screen.rivencraft.ink_mixer.chance": "%s: %s%%",
"block.rivencraft.crystal_block": "Crystal Block",
"block.rivencraft.budding_crystal": "Budding Crystal",
"block.rivencraft.small_crystal_bud": "Small Crystal Bud",
"block.rivencraft.medium_crystal_bud": "Medium Crystal Bud",
"block.rivencraft.large_crystal_bud": "Large Crystal Bud",
"block.rivencraft.crystal_cluster": "Crystal Cluster",
"itemGroup.rivencraft": "Rivencraft",
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages",
"symbol.rivencraft.feature.poplar_trees": "Poplar Trees",
"symbol.rivencraft.feature.poplar_trees.description": "Add red, orange, and yellow poplar trees to surface terrain with suitable soil.",
"symbol.rivencraft.feature.fallen_poplar_trees": "Fallen Poplar Trees",
"symbol.rivencraft.feature.fallen_poplar_trees.description": "Add fallen poplar logs and stumps to surface terrain with suitable soil.",
"symbol.rivencraft.feature.shelf_mushrooms": "Shelf Mushrooms",
"symbol.rivencraft.feature.shelf_mushrooms.description": "Add shelf mushrooms to exposed log faces on any terrain, including Void. No tree page is required.",
"symbol.rivencraft.feature.red_shrubs": "Red Shrubs",
"symbol.rivencraft.feature.red_shrubs.description": "Add red shrub patches to surface terrain with suitable ground.",
"symbol.rivencraft.feature.abandoned_camps": "Abandoned Camps",
"symbol.rivencraft.feature.abandoned_camps.description": "Allow abandoned camps on surface noise terrain, including outside their usual biomes.",
"symbol.rivencraft.block.generated.description": "Modify the next compatible feature. Without a following feature, choose a compatible feature from the Age seed.",
"symbol.rivencraft.biome.native.description": "Use the terrain's default biome source. Cave uses Tiny regions from recognized Cave/Nether biomes. Do not include Biome pages.",
"symbol.rivencraft.feature.dungeons.description": "Add monster-room dungeon attempts throughout surface noise or Cave terrain, including Nether cave biomes. Not Flat or Void.",
"symbol.rivencraft.biome.single.description": "Use one biome across the Age. If omitted, choose an eligible biome from the Age seed.",
"symbol.rivencraft.terrain.flat.description": "A level, biome-decorated landscape with its foundation at Y=0.",
"symbol.rivencraft.environment.accelerated.description": "Adds extra random block ticks near players in this Age. Repeated pages stack within server tick budgets; does not speed up entities or machines.",
"symbol.rivencraft.feature.floating_islands.description": "Add high floating islands alongside noise, Flat, or Void terrain; not Cave terrain.",
"symbol.rivencraft.feature.caves.description": "Add extra cave carving to noise terrain, including Cave; not Flat or Void.",
"symbol.rivencraft.feature.ravines.description": "Add canyon carving to noise terrain; not Flat or Void. Skips Nether biomes.",
"symbol.rivencraft.feature.mineshafts.description": "Allow mineshafts in noise terrain, including Cave, regardless of normal biome eligibility.",
"symbol.rivencraft.feature.nether_fortresses.description": "Allow Nether fortresses in Cave terrain; the authored page overrides biome eligibility.",
"symbol.rivencraft.feature.strongholds.description": "Allow strongholds in surface noise or Flat terrain, including outside their usual biomes.",
"symbol.rivencraft.feature.villages.description": "Allow villages in surface noise or Flat terrain, including outside their usual biomes.",
"symbol.rivencraft.feature.trial_chambers.description": "Allow underground Trial Chambers in noise terrain, including Cave, regardless of biome eligibility.",
"symbol.rivencraft.feature.bastions.description": "Allow Bastion Remnants in Cave terrain; the authored page overrides biome eligibility.",
"symbol.rivencraft.feature.buried_treasure.description": "Allow buried treasure in surface noise terrain; biome eligibility is overridden, but suitable terrain is required.",
"symbol.rivencraft.feature.shipwrecks.description": "Allow shipwrecks in surface noise terrain, including outside their usual ocean and beach biomes.",
"symbol.rivencraft.feature.ocean_ruins.description": "Allow ocean ruins in surface noise terrain, including outside their usual biomes.",
"symbol.rivencraft.feature.pillager_outposts.description": "Allow pillager outposts in surface noise terrain, including outside their usual biomes.",
"symbol.rivencraft.feature.ruined_portals.description": "Allow ruined portals in noise terrain, including Cave, regardless of normal biome eligibility.",
"symbol.rivencraft.feature.nether_fossils.description": "Allow Nether fossils in Cave terrain; the authored page overrides biome eligibility.",
"symbol.rivencraft.feature.ancient_cities.description": "Allow underground Ancient Cities in noise terrain, including Cave, regardless of biome eligibility.",
"symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes."
}
+11
View File
@@ -0,0 +1,11 @@
pluginManagement {
repositories {
gradlePluginPortal()
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
rootProject.name = 'rivencraft-mc263-port'
@@ -0,0 +1,35 @@
package net.dnorberg.rivencraft;
import net.neoforged.neoforge.common.ModConfigSpec;
/** Server-owned gameplay policies adapted from 1.21.1. */
public final class PortConfig {
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
public static final ModConfigSpec SPEC;
public static final ModConfigSpec.IntValue SYMBOL_INK_COST;
public static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
public static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS;
public static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS;
static {
var builder = new ModConfigSpec.Builder();
builder.push("descriptive_books");
RETURN_LINK_MODE = builder.comment("Controls automatic Return Links for successful Descriptive Book travel.",
"ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.")
.defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
builder.pop();
builder.push("writing");
SYMBOL_INK_COST = builder.comment("Ink units consumed for one Writing Desk symbol copy.")
.defineInRange("symbol_ink_cost", 1, 1, 16);
builder.pop();
builder.push("accelerated_age");
ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded player-nearby chunks given extra random ticks per Age tick.")
.defineInRange("max_loaded_chunks", 8, 1, 64);
ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables Accelerated.")
.defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536);
builder.pop();
SPEC = builder.build();
}
private PortConfig() {}
}
@@ -0,0 +1,229 @@
package net.dnorberg.rivencraft;
import com.mojang.logging.LogUtils;
import com.mojang.serialization.Codec;
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.dnorberg.rivencraft.age.PortAgeCommands;
import net.dnorberg.rivencraft.age.AgeCatalogSync;
import net.dnorberg.rivencraft.block.BuddingCrystalBlock;
import net.dnorberg.rivencraft.block.PortBookBinderBlock;
import net.dnorberg.rivencraft.block.InkMixerBlock;
import net.dnorberg.rivencraft.block.StarFissureBlock;
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.registry.PortBiomeSources;
import net.dnorberg.rivencraft.registry.PortChunkGenerators;
import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.AmethystClusterBlock;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.neoforge.registries.DeferredBlock;
import net.neoforged.neoforge.registries.DeferredItem;
import net.neoforged.neoforge.registries.DeferredRegister;
import net.neoforged.neoforge.common.extensions.IMenuTypeExtension;
import java.util.function.Supplier;
import net.neoforged.neoforge.common.NeoForge;
import org.slf4j.Logger;
/** First 26.3 registry slice; gameplay remains in the 1.21.1 target for now. */
@Mod(Rivencraft.MOD_ID)
public final class Rivencraft {
public static final String MOD_ID = "rivencraft";
public static final Logger LOGGER = LogUtils.getLogger();
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID);
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
private static final DeferredRegister<net.minecraft.world.entity.EntityType<?>> ENTITIES =
DeferredRegister.create(Registries.ENTITY_TYPE, MOD_ID);
public static final Supplier<net.minecraft.world.entity.EntityType<net.dnorberg.rivencraft.entity.DroppedBookEntity>>
DROPPED_BOOK = ENTITIES.register("dropped_book", () -> net.minecraft.world.entity.EntityType.Builder
.<net.dnorberg.rivencraft.entity.DroppedBookEntity>of(
net.dnorberg.rivencraft.entity.DroppedBookEntity::new, net.minecraft.world.entity.MobCategory.MISC)
.sized(0.65F, 0.4F).clientTrackingRange(10).updateInterval(20)
.build(net.minecraft.resources.ResourceKey.create(Registries.ENTITY_TYPE,
Identifier.fromNamespaceAndPath(MOD_ID, "dropped_book"))));
private static final DeferredRegister<SoundEvent> SOUNDS =
DeferredRegister.create(Registries.SOUND_EVENT, MOD_ID);
public static final Supplier<SoundEvent> LINK = sound("linking.link");
public static final Supplier<SoundEvent> LINK_DISARM = sound("linking.link-disarm");
public static final Supplier<SoundEvent> LINK_INTRA = sound("linking.link-intra");
public static final Supplier<SoundEvent> LINK_FISSURE = sound("linking.link-fissure");
public static final Supplier<SoundEvent> LINK_PORTAL = sound("linking.link-portal");
public static final Supplier<SoundEvent> METEOR_ROAR = sound("entity.meteor.roar");
private static Supplier<SoundEvent> sound(String name) {
return SOUNDS.register(name, () -> SoundEvent.createVariableRangeEvent(
Identifier.fromNamespaceAndPath(MOD_ID, name)));
}
private static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITY_TYPES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, MOD_ID);
private static final DeferredRegister<MenuType<?>> MENUS =
DeferredRegister.create(Registries.MENU, MOD_ID);
private static final DeferredRegister<RecipeSerializer<?>> RECIPE_SERIALIZERS =
DeferredRegister.create(Registries.RECIPE_SERIALIZER, MOD_ID);
private static final DeferredRegister.DataComponents COMPONENTS =
DeferredRegister.createDataComponents(Registries.DATA_COMPONENT_TYPE, MOD_ID);
public static final Supplier<DataComponentType<Identifier>> AGE_BOOK_ID = COMPONENTS.registerComponentType(
"age_book_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
public static final Supplier<DataComponentType<net.dnorberg.rivencraft.writing.NotebookContents>> NOTEBOOK_CONTENTS =
COMPONENTS.registerComponentType("notebook_contents", builder -> builder
.persistent(net.dnorberg.rivencraft.writing.NotebookContents.CODEC)
.networkSynchronized(net.dnorberg.rivencraft.writing.NotebookContents.STREAM_CODEC));
public static final Supplier<DataComponentType<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
/** A present component distinguishes Link Panels from unwritten base Pages. */
public static final Supplier<DataComponentType<Boolean>> LINK_PANEL_PAGE = COMPONENTS.registerComponentType(
"link_panel_page", builder -> builder.persistent(Codec.BOOL)
.networkSynchronized(ByteBufCodecs.BOOL));
public static final Supplier<DataComponentType<Identifier>> SYMBOL_PAGE_ID = COMPONENTS.registerComponentType(
"symbol_page_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
/** Ordered authored page IDs remain on the finished book for later port slices. */
public static final Supplier<DataComponentType<java.util.List<Identifier>>> BOOK_SYMBOLS = COMPONENTS.registerComponentType(
"book_symbols", builder -> builder.persistent(Identifier.CODEC.listOf())
.networkSynchronized(ByteBufCodecs.<io.netty.buffer.ByteBuf, Identifier>list(252)
.apply(Identifier.STREAM_CODEC)));
/** Set only on temporary GUI stacks; NeoForge 26.3 requires a registered codec. */
public static final Supplier<DataComponentType<Boolean>> PAGE_GLYPH_ONLY = COMPONENTS.registerComponentType(
"page_glyph_only", builder -> builder.persistent(Codec.BOOL)
.networkSynchronized(ByteBufCodecs.BOOL));
public static final Supplier<DataComponentType<Integer>> INK_REMAINING = COMPONENTS.registerComponentType(
"ink_remaining", builder -> builder.persistent(Codec.INT)
.networkSynchronized(ByteBufCodecs.VAR_INT));
public static final Supplier<DataComponentType<LinkProperties>> LINK_PROPERTIES = COMPONENTS.registerComponentType(
"link_properties", builder -> builder.persistent(LinkProperties.CODEC)
.networkSynchronized(LinkProperties.STREAM_CODEC));
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
public static final DeferredBlock<net.dnorberg.rivencraft.block.CloudPlatformBlock> CLOUD_PLATFORM =
BLOCKS.registerBlock("cloud_platform", net.dnorberg.rivencraft.block.CloudPlatformBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)
.mapColor(MapColor.COLOR_BLUE).strength(0.3F).noLootTable());
public static final DeferredBlock<StarFissureBlock> STAR_FISSURE =
BLOCKS.registerBlock("star_fissure", StarFissureBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6));
public static final Supplier<BlockEntityType<StarFissureBlockEntity>>
STAR_FISSURE_BLOCK_ENTITY = BLOCK_ENTITY_TYPES.register("star_fissure", () -> new BlockEntityType<>(
StarFissureBlockEntity::new, STAR_FISSURE.get()));
public static final DeferredBlock<PortBookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
"book_binder", PortBookBinderBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredItem<BlockItem> BOOK_BINDER_ITEM = ITEMS.registerSimpleBlockItem(BOOK_BINDER);
public static final DeferredBlock<InkMixerBlock> INK_MIXER = BLOCKS.registerBlock(
"ink_mixer", InkMixerBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredItem<BlockItem> INK_MIXER_ITEM = ITEMS.registerSimpleBlockItem(INK_MIXER);
public static final Supplier<BlockEntityType<PortBookBinderBlockEntity>> BOOK_BINDER_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("book_binder", () ->
new BlockEntityType<>(PortBookBinderBlockEntity::new, BOOK_BINDER.get()));
public static final Supplier<MenuType<PortBookBinderMenu>> BOOK_BINDER_MENU =
MENUS.register("book_binder", () -> IMenuTypeExtension.create(PortBookBinderMenu::new));
public static final Supplier<BlockEntityType<InkMixerBlockEntity>> INK_MIXER_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("ink_mixer", () ->
new BlockEntityType<>(InkMixerBlockEntity::new, INK_MIXER.get()));
public static final Supplier<MenuType<InkMixerMenu>> INK_MIXER_MENU =
MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new));
public static final DeferredBlock<net.dnorberg.rivencraft.block.WritingDeskBlock> WRITING_DESK =
BLOCKS.registerBlock("writing_desk", net.dnorberg.rivencraft.block.WritingDeskBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredItem<BlockItem> WRITING_DESK_ITEM = ITEMS.registerSimpleBlockItem(WRITING_DESK);
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity>> WRITING_DESK_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("writing_desk", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity::new, WRITING_DESK.get()));
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.WritingDeskMenu>> WRITING_DESK_MENU =
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.WritingDeskMenu::new));
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.NotebookMenu>> NOTEBOOK_MENU =
MENUS.register("notebook", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.NotebookMenu::new));
public static final DeferredItem<net.dnorberg.rivencraft.item.NotebookItem> NOTEBOOK =
ITEMS.registerItem("notebook", net.dnorberg.rivencraft.item.NotebookItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.CollationFolderItem> COLLATION_FOLDER =
ITEMS.registerItem("collation_folder", net.dnorberg.rivencraft.item.CollationFolderItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.SymbolPortfolioItem> SYMBOL_PORTFOLIO =
ITEMS.registerItem("symbol_portfolio", net.dnorberg.rivencraft.item.SymbolPortfolioItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.SealedNotebookItem> SEALED_NOTEBOOK =
ITEMS.registerItem("sealed_notebook", net.dnorberg.rivencraft.item.SealedNotebookItem::new, properties -> properties.stacksTo(16));
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK);
public static final DeferredBlock<BuddingCrystalBlock> BUDDING_CRYSTAL = BLOCKS.registerBlock(
"budding_crystal", BuddingCrystalBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.BUDDING_AMETHYST).mapColor(MapColor.COLOR_BLUE));
public static final DeferredItem<BlockItem> BUDDING_CRYSTAL_ITEM = ITEMS.registerSimpleBlockItem(BUDDING_CRYSTAL);
public static final DeferredBlock<AmethystClusterBlock> SMALL_CRYSTAL_BUD = BLOCKS.registerBlock(
"small_crystal_bud", properties -> new AmethystClusterBlock(3.0F, 4.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.SMALL_AMETHYST_BUD));
public static final DeferredItem<BlockItem> SMALL_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(SMALL_CRYSTAL_BUD);
public static final DeferredBlock<AmethystClusterBlock> MEDIUM_CRYSTAL_BUD = BLOCKS.registerBlock(
"medium_crystal_bud", properties -> new AmethystClusterBlock(4.0F, 3.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.MEDIUM_AMETHYST_BUD));
public static final DeferredItem<BlockItem> MEDIUM_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(MEDIUM_CRYSTAL_BUD);
public static final DeferredBlock<AmethystClusterBlock> LARGE_CRYSTAL_BUD = BLOCKS.registerBlock(
"large_crystal_bud", properties -> new AmethystClusterBlock(5.0F, 3.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.LARGE_AMETHYST_BUD));
public static final DeferredItem<BlockItem> LARGE_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(LARGE_CRYSTAL_BUD);
public static final DeferredBlock<AmethystClusterBlock> CRYSTAL_CLUSTER = BLOCKS.registerBlock(
"crystal_cluster", properties -> new AmethystClusterBlock(7.0F, 3.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_CLUSTER));
public static final DeferredItem<BlockItem> CRYSTAL_CLUSTER_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_CLUSTER);
public static final DeferredItem<Item> CRYSTAL_SHARD = ITEMS.registerSimpleItem("crystal_shard");
public static final DeferredItem<PageItem> PAGE = ITEMS.registerItem("page", PageItem::new);
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
"ink_vial", InkVialItem::new, properties -> properties.stacksTo(16));
public static final DeferredItem<DescriptiveBookItem> DESCRIPTIVE_BOOK = ITEMS.registerItem(
"descriptive_book", DescriptiveBookItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<LinkingBookItem> LINKING_BOOK = ITEMS.registerItem(
"linking_book", LinkingBookItem::new, properties -> properties.stacksTo(1));
public static final Supplier<RecipeSerializer<LinkingBookRecipe>> LINKING_BOOK_RECIPE =
RECIPE_SERIALIZERS.register("linking_book", () -> LinkingBookRecipe.SERIALIZER);
public Rivencraft(IEventBus modEventBus, net.neoforged.fml.ModContainer container) {
container.registerConfig(net.neoforged.fml.config.ModConfig.Type.LOCAL, PortConfig.SPEC);
BLOCKS.register(modEventBus);
SOUNDS.register(modEventBus);
ITEMS.register(modEventBus);
ENTITIES.register(modEventBus);
BLOCK_ENTITY_TYPES.register(modEventBus);
MENUS.register(modEventBus);
COMPONENTS.register(modEventBus);
RECIPE_SERIALIZERS.register(modEventBus);
ModCreativeTabs.register(modEventBus);
PortBiomeSources.register(modEventBus);
PortChunkGenerators.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost);
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,37 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
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,
Map<Identifier, PortAgeColors> colors,
Map<Identifier, PortDayCycle> dayCycles) 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,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortAgeColors.STREAM_CODEC), AgeCatalogPayload::colors,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortDayCycle.STREAM_CODEC), AgeCatalogPayload::dayCycles,
AgeCatalogPayload::new
);
public AgeCatalogPayload {
dimensions = List.copyOf(dimensions);
colors = Map.copyOf(colors);
dayCycles = Map.copyOf(dayCycles);
}
@Override
public Type<AgeCatalogPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,103 @@
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("10");
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,
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server)));
}
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,215 @@
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)),
Codec.unboundedMap(Identifier.CODEC, Identifier.CODEC).optionalFieldOf("biome_overrides", Map.of())
.forGetter(index -> index.biomeOverrides),
Codec.unboundedMap(Identifier.CODEC, PortBiomePlan.CODEC).optionalFieldOf("biome_plans", Map.of())
.forGetter(index -> index.biomePlans),
Codec.unboundedMap(Identifier.CODEC,
Codec.STRING.xmap(PortAgeTerrain::valueOf, PortAgeTerrain::name))
.optionalFieldOf("terrain", Map.of()).forGetter(index -> index.terrain),
Codec.unboundedMap(Identifier.CODEC, PortRockPlan.CODEC).optionalFieldOf("rocks", Map.of())
.forGetter(index -> index.rocks),
Codec.unboundedMap(Identifier.CODEC, PortVegetationPlan.CODEC)
.optionalFieldOf("vegetation", Map.of()).forGetter(index -> index.vegetation),
Codec.unboundedMap(Identifier.CODEC, Codec.intRange(1, 32))
.optionalFieldOf("dungeons", Map.of()).forGetter(index -> index.dungeons),
Codec.unboundedMap(Identifier.CODEC, PortLakePlan.CODEC)
.optionalFieldOf("lakes", Map.of()).forGetter(index -> index.lakes),
Codec.unboundedMap(Identifier.CODEC, PortTerrainFeaturePlan.CODEC)
.optionalFieldOf("terrain_features", Map.of()).forGetter(index -> index.terrainFeatures),
Codec.unboundedMap(Identifier.CODEC, PortLighting.CODEC)
.optionalFieldOf("lighting", Map.of()).forGetter(index -> index.lighting),
Codec.unboundedMap(Identifier.CODEC, PortSkyStyle.CODEC)
.optionalFieldOf("sky", Map.of()).forGetter(index -> index.sky),
Codec.unboundedMap(Identifier.CODEC, PortAgeColors.CODEC)
.optionalFieldOf("colors", Map.of()).forGetter(index -> index.colors),
Codec.unboundedMap(Identifier.CODEC, PortDayCycle.CODEC)
.optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles)
).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 final Map<Identifier, Identifier> biomeOverrides;
private final Map<Identifier, PortBiomePlan> biomePlans;
private final Map<Identifier, PortAgeTerrain> terrain;
private final Map<Identifier, PortRockPlan> rocks;
private final Map<Identifier, PortVegetationPlan> vegetation;
private final Map<Identifier, Integer> dungeons;
private final Map<Identifier, PortLakePlan> lakes;
private final Map<Identifier, PortTerrainFeaturePlan> terrainFeatures;
private final Map<Identifier, PortLighting> lighting;
private final Map<Identifier, PortSkyStyle> sky;
private final Map<Identifier, PortAgeColors> colors;
private final Map<Identifier, PortDayCycle> dayCycles;
private AgeLevelIndex() {
this.ages = new LinkedHashMap<>();
this.retired = new LinkedHashSet<>();
this.biomeOverrides = new LinkedHashMap<>();
this.biomePlans = new LinkedHashMap<>();
this.terrain = new LinkedHashMap<>();
this.rocks = new LinkedHashMap<>();
this.vegetation = new LinkedHashMap<>();
this.dungeons = new LinkedHashMap<>();
this.lakes = new LinkedHashMap<>();
this.terrainFeatures = new LinkedHashMap<>();
this.lighting = new LinkedHashMap<>();
this.sky = new LinkedHashMap<>();
this.colors = new LinkedHashMap<>();
this.dayCycles = new LinkedHashMap<>();
}
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired,
Map<Identifier, Identifier> biomeOverrides,
Map<Identifier, PortBiomePlan> biomePlans,
Map<Identifier, PortAgeTerrain> terrain,
Map<Identifier, PortRockPlan> rocks,
Map<Identifier, PortVegetationPlan> vegetation,
Map<Identifier, Integer> dungeons,
Map<Identifier, PortLakePlan> lakes,
Map<Identifier, PortTerrainFeaturePlan> terrainFeatures,
Map<Identifier, PortLighting> lighting,
Map<Identifier, PortSkyStyle> sky, Map<Identifier, PortAgeColors> colors,
Map<Identifier, PortDayCycle> dayCycles) {
this.ages = new LinkedHashMap<>(ages);
this.retired = new LinkedHashSet<>(retired);
this.biomeOverrides = new LinkedHashMap<>(biomeOverrides);
this.biomePlans = new LinkedHashMap<>(biomePlans);
this.terrain = new LinkedHashMap<>(terrain);
this.rocks = new LinkedHashMap<>(rocks);
this.vegetation = new LinkedHashMap<>(vegetation);
this.dungeons = new LinkedHashMap<>(dungeons);
this.lakes = new LinkedHashMap<>(lakes);
this.terrainFeatures = new LinkedHashMap<>(terrainFeatures);
this.lighting = new LinkedHashMap<>(lighting);
this.sky = new LinkedHashMap<>(sky);
this.colors = new LinkedHashMap<>(colors);
this.dayCycles = new LinkedHashMap<>(dayCycles);
}
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);
}
PortBiomePlan biomePlan(Identifier id) {
PortBiomePlan plan = biomePlans.get(id);
if (plan != null) return plan;
Identifier oldBiome = biomeOverrides.get(id);
return oldBiome == null ? null : new PortBiomePlan(PortBiomePlan.Distribution.SINGLE, List.of(oldBiome));
}
PortAgeTerrain terrain(Identifier id) {
return terrain.getOrDefault(id, PortAgeTerrain.STANDARD);
}
PortRockPlan rocks(Identifier id) { return rocks.get(id); }
PortVegetationPlan vegetation(Identifier id) { return vegetation.get(id); }
int dungeons(Identifier id) { return dungeons.getOrDefault(id, 0); }
PortLakePlan lakes(Identifier id) { return lakes.get(id); }
PortTerrainFeaturePlan terrainFeatures(Identifier id) { return terrainFeatures.get(id); }
PortLighting lighting(Identifier id) { return lighting.getOrDefault(id, PortLighting.NORMAL); }
PortSkyStyle sky(Identifier id) { return sky.getOrDefault(id, PortSkyStyle.OVERWORLD); }
PortAgeColors colors(Identifier id) { return colors.getOrDefault(id, PortAgeColors.NATIVE); }
Map<Identifier, PortAgeColors> colors() { return Map.copyOf(colors); }
PortDayCycle dayCycle(Identifier id) { return dayCycles.getOrDefault(id, PortDayCycle.NATIVE); }
Map<Identifier, PortDayCycle> dayCycles() { return Map.copyOf(dayCycles); }
void add(Identifier id, long seed, PortBiomePlan biomePlan, PortAgeTerrain ageTerrain,
PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, int dungeonCount,
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light, PortSkyStyle skyStyle,
PortAgeColors colorProfile, PortDayCycle dayCycle) {
if (contains(id)) {
throw new IllegalArgumentException("Age already exists: " + id);
}
ages.put(id, seed);
if (biomePlan != null) biomePlans.put(id, biomePlan);
if (ageTerrain != PortAgeTerrain.STANDARD) terrain.put(id, ageTerrain);
if (rockPlan != null) rocks.put(id, rockPlan);
if (vegetationPlan != null) vegetation.put(id, vegetationPlan);
if (dungeonCount > 0) dungeons.put(id, dungeonCount);
if (lakePlan != null) lakes.put(id, lakePlan);
if (terrainFeaturePlan != null) terrainFeatures.put(id, terrainFeaturePlan);
if (light != PortLighting.NORMAL) lighting.put(id, light);
if (skyStyle != PortSkyStyle.OVERWORLD) sky.put(id, skyStyle);
if (!colorProfile.equals(PortAgeColors.NATIVE)) colors.put(id, colorProfile);
if (!dayCycle.equals(PortDayCycle.NATIVE)) dayCycles.put(id, dayCycle);
setDirty();
}
void retire(Identifier id) {
if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id);
biomeOverrides.remove(id);
biomePlans.remove(id);
terrain.remove(id);
rocks.remove(id);
vegetation.remove(id);
dungeons.remove(id);
lakes.remove(id);
terrainFeatures.remove(id);
lighting.remove(id);
sky.remove(id);
colors.remove(id);
dayCycles.remove(id);
retired.add(id);
setDirty();
}
void restoreAfterFailedRetirement(Identifier id, long seed, PortBiomePlan biomePlan,
PortAgeTerrain ageTerrain, PortRockPlan rockPlan,
PortVegetationPlan vegetationPlan, int dungeonCount,
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light,
PortSkyStyle skyStyle, PortAgeColors colorProfile, PortDayCycle dayCycle) {
if (!retired.remove(id)) throw new IllegalStateException("Age is not retired: " + id);
ages.put(id, seed);
if (biomePlan != null) biomePlans.put(id, biomePlan);
if (ageTerrain != PortAgeTerrain.STANDARD) terrain.put(id, ageTerrain);
if (rockPlan != null) rocks.put(id, rockPlan);
if (vegetationPlan != null) vegetation.put(id, vegetationPlan);
if (dungeonCount > 0) dungeons.put(id, dungeonCount);
if (lakePlan != null) lakes.put(id, lakePlan);
if (terrainFeaturePlan != null) terrainFeatures.put(id, terrainFeaturePlan);
if (light != PortLighting.NORMAL) lighting.put(id, light);
if (skyStyle != PortSkyStyle.OVERWORLD) sky.put(id, skyStyle);
if (!colorProfile.equals(PortAgeColors.NATIVE)) colors.put(id, colorProfile);
if (!dayCycle.equals(PortDayCycle.NATIVE)) dayCycles.put(id, dayCycle);
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,119 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import net.dnorberg.rivencraft.PortConfig;
import net.minecraft.resources.Identifier;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.SectionPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.util.RandomSource;
/** Extra random block ticks from authored Accelerated pages, bounded to loaded player chunks. */
public final class PortAcceleratedTicks {
public static final Identifier PAGE = Identifier.fromNamespaceAndPath("rivencraft", "environment/accelerated");
private PortAcceleratedTicks() {}
public static int pageCount(List<Identifier> pages) {
return (int) pages.stream().filter(PAGE::equals).count();
}
public static void configure(ServerLevel level, int pages) {
if (!(level instanceof PortAgeLevel)) throw new IllegalArgumentException("Not a Rivencraft Age");
level.getDataStorage().computeIfAbsent(PortAccelerationSettings.TYPE).setPages(pages);
level.getDataStorage().saveAndJoin();
}
public static int pages(ServerLevel level) {
return level.getDataStorage().computeIfAbsent(PortAccelerationSettings.TYPE).pages();
}
public static void onLevelTickPost(LevelTickEvent.Post event) {
if (event.getLevel() instanceof PortAgeLevel level) tick(level);
}
static void tick(ServerLevel level) {
int maximumAttempts = PortConfig.ACCELERATED_MAX_ATTEMPTS.get();
if (maximumAttempts == 0) return;
int pages = pages(level);
if (pages == 0) return;
List<ChunkPos> chunks = loadedPlayerChunks(level);
if (chunks.isEmpty()) return;
int maximumChunks = Math.min(chunks.size(), PortConfig.ACCELERATED_MAX_CHUNKS.get());
int first = Math.floorMod(level.getServer().getTickCount(), chunks.size());
List<TickSection> eligible = new ArrayList<>();
for (int offset = 0; offset < maximumChunks; offset++) {
ChunkPos pos = chunks.get((first + offset) % chunks.size());
LevelChunk chunk = level.getChunkSource().getChunkNow(pos.x(), pos.z());
if (chunk == null) continue;
LevelChunkSection[] sections = chunk.getSections();
for (int sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
LevelChunkSection section = sections[sectionIndex];
if (!section.isRandomlyTickingBlocks()) continue;
int baseY = SectionPos.sectionToBlockCoord(chunk.getSectionYFromSectionIndex(sectionIndex));
eligible.add(new TickSection(pos, baseY, section));
}
}
if (eligible.isEmpty()) return;
// Legacy attempts three positions per random-ticking section per page. Divide
// the bounded total between sections before ticking any one of them, so a
// lower section cannot exhaust the entire budget ahead of surface crops.
int total = attemptBudget(pages, eligible.size(), maximumAttempts);
int perSection = total / eligible.size();
int remainder = total % eligible.size();
int firstSection = Math.floorMod(level.getServer().getTickCount(), eligible.size());
RandomSource random = level.getRandom();
for (int offset = 0; offset < eligible.size(); offset++) {
TickSection candidate = eligible.get((firstSection + offset) % eligible.size());
int samples = perSection + (offset < remainder ? 1 : 0);
for (int sample = 0; sample < samples; sample++) {
int x = random.nextInt(16);
int y = random.nextInt(16);
int z = random.nextInt(16);
BlockState state = candidate.section().getBlockState(x, y, z);
if (state.isRandomlyTicking()) {
state.randomTick(level, new BlockPos(candidate.chunk().getMinBlockX() + x,
candidate.baseY() + y, candidate.chunk().getMinBlockZ() + z), random);
}
}
}
}
static int attemptBudget(int pages, int sections, int maximumAttempts) {
if (pages < 0 || sections < 0 || maximumAttempts < 0)
throw new IllegalArgumentException("Negative accelerated tick budget input");
if (sections == 0) return 0;
long perSection = 3L * pages;
return perSection > maximumAttempts / (long) sections
? maximumAttempts : (int) (perSection * sections);
}
private static List<ChunkPos> loadedPlayerChunks(ServerLevel level) {
Set<ChunkPos> chunks = new LinkedHashSet<>();
for (var player : level.players()) {
ChunkPos center = player.chunkPosition();
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
int chunkX = center.x() + x;
int chunkZ = center.z() + z;
if (level.getChunkSource().hasChunk(chunkX, chunkZ)) {
chunks.add(new ChunkPos(chunkX, chunkZ));
}
}
}
}
return new ArrayList<>(chunks);
}
private record TickSection(ChunkPos chunk, int baseY, LevelChunkSection section) {}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Age-local authored page count, retained across unload, restart and reseeding. */
final class PortAccelerationSettings extends SavedData {
private static final Codec<PortAccelerationSettings> CODEC = Codec.intRange(0, Integer.MAX_VALUE)
.fieldOf("pages").codec().xmap(PortAccelerationSettings::new, settings -> settings.pages);
static final SavedDataType<PortAccelerationSettings> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "acceleration"),
() -> new PortAccelerationSettings(0), CODEC);
private int pages;
private PortAccelerationSettings(int pages) { this.pages = pages; }
int pages() { return pages; }
void setPages(int pages) {
if (pages < 0) throw new IllegalArgumentException("Negative Accelerated page count");
this.pages = pages;
setDirty();
}
}
@@ -0,0 +1,132 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Map;
import java.util.Random;
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.world.level.Level;
import org.joml.Vector3f;
import org.joml.Vector3fc;
/** Frozen shared-color writing, adapted from 1.21.1 AgeSkyProfile. -1 means native. */
public record PortAgeColors(int sky, int ambient, int celestial) {
public static final PortAgeColors NATIVE = new PortAgeColors(-1, -1);
public static final Codec<PortAgeColors> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.intRange(-1, 0xffffff).fieldOf("sky").forGetter(PortAgeColors::sky),
Codec.intRange(-1, 0xffffff).fieldOf("ambient").forGetter(PortAgeColors::ambient),
Codec.intRange(-1, 0xffffff).optionalFieldOf("celestial", -1).forGetter(PortAgeColors::celestial)
).apply(instance, PortAgeColors::new));
public static final StreamCodec<RegistryFriendlyByteBuf, PortAgeColors> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.INT, PortAgeColors::sky, ByteBufCodecs.INT, PortAgeColors::ambient,
ByteBufCodecs.INT, PortAgeColors::celestial, PortAgeColors::new);
public static final Identifier AMBIENT = Identifier.fromNamespaceAndPath("rivencraft", "lighting/ambient_color");
private static volatile Map<Identifier, PortAgeColors> clientColors = Map.of();
public PortAgeColors {
if (sky < -1 || sky > 0xffffff || ambient < -1 || ambient > 0xffffff
|| celestial < -1 || celestial > 0xffffff)
throw new IllegalArgumentException("Invalid Age color");
}
public PortAgeColors(int sky, int ambient) { this(sky, ambient, -1); }
public static void updateClient(Map<Identifier, PortAgeColors> colors) { clientColors = Map.copyOf(colors); }
public static PortAgeColors forLevel(Level level) {
if (level instanceof PortAgeLevel age) return age.colors();
return level.isClientSide() ? clientColors.getOrDefault(level.dimension().identifier(), NATIVE) : NATIVE;
}
public static boolean isColor(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "color/red", "color/green", "color/blue", "color/white", "color/black" -> true;
default -> false;
};
}
public static int number(Identifier page) {
if (!page.getNamespace().equals("rivencraft") || !page.getPath().startsWith("number/")) return -1;
try {
int value = Integer.parseInt(page.getPath().substring(7));
return value >= 0 && value <= 24 ? value : -1;
} catch (NumberFormatException exception) { return -1; }
}
public static boolean isModifier(Identifier page) { return isColor(page) || number(page) >= 0 || page.equals(AMBIENT); }
public static boolean validNumbers(List<Identifier> pages) {
for (int i = 0; i < pages.size(); i++)
if (number(pages.get(i)) >= 0 && (i + 1 == pages.size()
|| !isColor(pages.get(i + 1)) && !PortDayCycle.isPeriod(pages.get(i + 1))
&& !PortDayCycle.isPhase(pages.get(i + 1)) && !PortDayCycle.isAngle(pages.get(i + 1))
&& !PortDayCycle.isSize(pages.get(i + 1)))) return false;
return true;
}
public static PortAgeColors compile(List<Identifier> pages, long seed) {
if (!validNumbers(pages)) throw new IllegalArgumentException("Number pages must precede a supported Color, Length, Phase, Angle, or Size modifier");
int red = 0, green = 0, blue = 0, sky = -1, ambient = -1, celestial = -1;
boolean written = false;
for (int i = 0; i < pages.size(); i++) {
Identifier page = pages.get(i);
if (isColor(page)) {
int amount = i > 0 ? number(pages.get(i - 1)) : -1;
if (amount < 0) amount = new Random(seed ^ (0x9E3779B97F4A7C15L * (i + 1L))).nextInt(25);
amount *= 10;
written = true;
switch (page.getPath()) {
case "color/red" -> red += amount;
case "color/green" -> green += amount;
case "color/blue" -> blue += amount;
case "color/white" -> { red += amount; green += amount; blue += amount; }
case "color/black" -> { red -= amount; green -= amount; blue -= amount; }
}
} else if (PortSkyStyle.forPage(page) != null || page.equals(AMBIENT)
|| PortDayCycle.isController(page)) {
if (PortDayCycle.isController(page)) celestial = -1;
if (written) {
int rgb = clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
if (page.equals(AMBIENT)) ambient = rgb;
else if (PortDayCycle.isController(page)) celestial = rgb;
else sky = rgb;
}
red = green = blue = 0;
written = false;
}
}
if (written) sky = clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
return new PortAgeColors(sky, ambient, celestial);
}
/** Only pageless random Ages receive a random sky tint, as in 1.21.1. */
public static PortAgeColors resolve(List<Identifier> pages, long seed) {
if (!pages.isEmpty()) return compile(pages, seed);
var random = new Random(seed ^ 0x534B5950524F464CL);
random.nextInt(5); // Root sky-style draw precedes the sky-color draw.
return new PortAgeColors(randomColor(random, true), -1);
}
/** Shared palette; retain .85's truncation for already-defined body defaults. */
static int randomColor(Random random, boolean rounded) {
int[] palette = {0xB74A50, 0xD9834B, 0xDBC574, 0x5AAB80, 0x6FA8DF, 0x9870B9, 0xD5DBE1, 0x252531};
int count = 1 + random.nextInt(3), red = 0, green = 0, blue = 0;
for (int i = 0; i < count; i++) {
int color = palette[random.nextInt(palette.length)];
red += color >> 16 & 255;
green += color >> 8 & 255;
blue += color & 255;
}
int offset = rounded ? count / 2 : 0;
return (red + offset) / count << 16 | (green + offset) / count << 8 | (blue + offset) / count;
}
private static int clamp(int value) { return Math.max(0, Math.min(255, value)); }
public static Vector3fc vector(int rgb) {
return new Vector3f((rgb >> 16 & 255) / 255f, (rgb >> 8 & 255) / 255f, (rgb & 255) / 255f);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
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.dimension.LevelStem;
import net.minecraft.world.level.saveddata.WeatherData;
import net.minecraft.world.level.storage.DerivedLevelData;
/** Runtime Age with independent native weather, rather than 26.3's shared server cycle. */
final class PortAgeLevel extends ServerLevel {
private final WeatherData ageWeather;
private final PortWeatherSettings weatherSettings;
private final boolean cave;
private PortAgeColors colors = PortAgeColors.NATIVE;
private PortDayCycle dayCycle = PortDayCycle.NATIVE;
PortDayCycle dayCycle() { return dayCycle == null ? PortDayCycle.NATIVE : dayCycle; }
void configureDayCycle(PortDayCycle cycle) {
this.dayCycle = cycle;
environmentAttributes().invalidateTickCache();
updateSkyBrightness();
}
PortAgeColors colors() { return colors == null ? PortAgeColors.NATIVE : colors; }
void configureColors(PortAgeColors colors) {
this.colors = colors;
environmentAttributes().invalidateTickCache();
}
PortAgeLevel(MinecraftServer server, ResourceKey<Level> key, LevelStem stem,
long biomeZoomSeed, PortAgeTerrain terrain) {
super(server, server.executor, server.storageSource,
new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()),
key, stem, server.getWorldData().isDebugWorld(), biomeZoomSeed, List.of(), false);
ageWeather = getDataStorage().computeIfAbsent(WeatherData.TYPE);
weatherSettings = getDataStorage().computeIfAbsent(PortWeatherSettings.TYPE);
cave = terrain == PortAgeTerrain.CAVE;
PortAgeWeather.apply(this);
// The base constructor initializes visual levels from server weather.
// Replace them before publishing this Age or sending its join packets.
setRainLevel(ageWeather.isRaining() ? 1.0F : 0.0F);
setThunderLevel(ageWeather.isThundering() ? 1.0F : 0.0F);
}
@Override
public WeatherData getWeatherData() {
// The base constructor may query weather before our fields are initialized.
return ageWeather == null ? super.getWeatherData() : ageWeather;
}
PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); }
void configureWeather(PortWeatherMode mode) {
weatherSettings.setMode(mode);
PortAgeWeather.apply(this);
setRainLevel(ageWeather.isRaining() ? 1.0F : 0.0F);
setThunderLevel(ageWeather.isThundering() ? 1.0F : 0.0F);
getDataStorage().saveAndJoin();
}
}
@@ -0,0 +1,11 @@
package net.dnorberg.rivencraft.age;
/** Terrain variants currently backed by 26.3 vanilla noise settings. */
public enum PortAgeTerrain {
STANDARD,
AMPLIFIED,
ISLAND,
CAVE,
FLAT,
VOID
}
@@ -0,0 +1,165 @@
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";
}
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
if (landing != null) landing = isCaveTerrain(target)
? findFootedDestination(target, landing).orElse(null)
: findSurfaceLanding(target, landing.getX(), landing.getZ());
if (landing == null) landing = findLanding(target);
if (landing == null) return "No safe test landing available; remaining in source dimension";
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
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";
}
static BlockPos findLanding(ServerLevel target) {
if (isCaveTerrain(target)) {
// The cave roof is not a valid arrival surface. Search down from
// Legacy's Y=64 start, then construct the normal safety platform
// at that height only when no footed opening can be found.
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;
for (int y = Math.min(64, target.getMaxY() - 2); y > target.getMinY(); y--) {
BlockPos foot = new BlockPos(x, y, z);
if (hasFooting(target, foot)) return foot;
}
}
}
}
return makePlatform(target, new BlockPos(0, 64, 0));
}
BlockPos surface = findSurfaceLanding(target, 0, 0);
if (surface != null) return surface;
// Only build above the actual highest obstruction, never in a pocket
// beneath it. Void Ages retain the usual platform floor at Y=63.
BlockPos obstruction = highestObstruction(target, 0, 0);
int y = obstruction == null ? 64 : Math.max(64, obstruction.getY() + 3);
return makePlatform(target, new BlockPos(0, y, 0));
}
static BlockPos findSurfaceLanding(ServerLevel target, int originX, int originZ) {
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;
BlockPos floor = highestObstruction(target, originX + x, originZ + z);
if (floor != null && hasFooting(target, floor.above())) return floor.above();
}
}
}
return null;
}
/** First obstructing surface seen from the sky; never continue through a roof or liquid. */
static BlockPos highestObstruction(ServerLevel level, int x, int z) {
var chunk = level.getChunk(x >> 4, z >> 4);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(x, level.getMaxY() - 1, z);
for (int y = level.getMaxY() - 1; y >= level.getMinY(); y--) {
pos.setY(y);
var state = chunk.getBlockState(pos);
if (!state.isAir() && (!state.getFluidState().isEmpty()
|| state.is(net.minecraft.tags.BlockTags.LEAVES)
|| !state.getCollisionShape(level, pos).isEmpty())) return pos.immutable();
}
return null;
}
private static boolean isCaveTerrain(ServerLevel target) {
var generator = target.getChunkSource().getGenerator();
if (generator instanceof PortFeatureChunkGenerator features) generator = features.base();
return generator instanceof net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator noise
&& noise.generatorSettings().value().noiseSettings().equals(target.registryAccess()
.lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(net.minecraft.world.level.levelgen.NoiseGeneratorSettings.CAVES)
.value().noiseSettings());
}
private static BlockPos makePlatform(ServerLevel target, BlockPos foot) {
if (foot.getY() <= target.getMinY() || foot.getY() + 2 >= target.getMaxY()) return null;
// A carved pocket inside a flooded cave immediately refills. Refuse
// that landing instead of placing the player in submerged spawn space.
if (!target.getFluidState(foot).isEmpty() || !target.getFluidState(foot.above()).isEmpty()) return null;
for (Direction direction : Direction.Plane.HORIZONTAL) {
if (!target.getFluidState(foot.relative(direction)).isEmpty()
|| !target.getFluidState(foot.above().relative(direction)).isEmpty()) return null;
}
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
target.setBlock(foot.offset(x, -1, z), Rivencraft.CLOUD_PLATFORM.get().defaultBlockState(), 3);
}
}
target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3);
target.setBlock(foot.above(), Blocks.AIR.defaultBlockState(), 3);
return foot;
}
private static boolean hasFooting(ServerLevel target, BlockPos foot) {
if (foot.getY() <= target.getMinY() || foot.getY() + 1 >= target.getMaxY()
|| !target.getWorldBorder().isWithinBounds(foot)) return false;
BlockPos floor = foot.below();
var floorState = target.getBlockState(floor);
return (floorState.isFaceSturdy(target, floor, Direction.UP)
|| floorState.is(net.minecraft.tags.BlockTags.LEAVES))
&& floorState.getFluidState().isEmpty()
&& target.getFluidState(foot).isEmpty()
&& target.getFluidState(foot.above()).isEmpty()
&& passable(target, foot) && passable(target, foot.above());
}
private static boolean passable(ServerLevel level, BlockPos pos) {
var state = level.getBlockState(pos);
return state.getCollisionShape(level, pos).isEmpty() || state.is(net.minecraft.tags.BlockTags.LEAVES);
}
/** Same local-then-surface safe-return search used by 1.21.1 fissures. */
public static java.util.Optional<BlockPos> findFootedDestination(ServerLevel level, BlockPos origin) {
var local = BlockPos.betweenClosedStream(origin.offset(-4, -2, -4), origin.offset(4, 2, 4))
.map(BlockPos::immutable).sorted(java.util.Comparator.comparingDouble(pos -> pos.distSqr(origin)))
.filter(pos -> level.isInWorldBounds(pos) && level.getWorldBorder().isWithinBounds(pos))
.filter(pos -> hasFooting(level, pos)).findFirst();
if (local.isPresent()) return local;
for (int radius = 1; radius <= 64; radius++) {
final int ring = radius;
var found = BlockPos.betweenClosedStream(origin.offset(-ring, 0, -ring), origin.offset(ring, 0, ring))
.filter(pos -> Math.max(Math.abs(pos.getX() - origin.getX()), Math.abs(pos.getZ() - origin.getZ())) == ring)
.map(BlockPos::immutable)
.flatMap(pos -> {
BlockPos surface = new BlockPos(pos.getX(),
level.getHeight(Heightmap.Types.MOTION_BLOCKING, pos.getX(), pos.getZ()), pos.getZ());
return java.util.stream.Stream.of(surface, surface.above(), surface.below(),
surface.above(2), surface.below(2));
})
.filter(pos -> level.isInWorldBounds(pos) && level.getWorldBorder().isWithinBounds(pos))
.filter(pos -> hasFooting(level, pos)).findFirst();
if (found.isPresent()) return found;
}
return java.util.Optional.empty();
}
}
@@ -0,0 +1,27 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** World-wide record of Ages for which an ON_CREATION Return Link was issued. */
final class PortAgeVisits extends SavedData {
static final Codec<PortAgeVisits> CODEC = Identifier.CODEC.listOf().fieldOf("ages").codec()
.xmap(PortAgeVisits::new, visits -> visits.ages.stream().sorted().toList());
static final SavedDataType<PortAgeVisits> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "age_visits"),
() -> new PortAgeVisits(List.of()), CODEC);
private final Set<Identifier> ages;
PortAgeVisits(List<Identifier> ages) { this.ages = new HashSet<>(ages); }
boolean hasVisited(Identifier age) { return ages.contains(age); }
boolean markFirstVisit(Identifier age) {
if (!ages.add(age)) return false;
setDirty();
return true;
}
}
@@ -0,0 +1,52 @@
package net.dnorberg.rivencraft.age;
import java.util.Map;
import java.util.WeakHashMap;
import net.minecraft.world.level.saveddata.WeatherData;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** 1.21.1 weather policies, applied before the independent native Age cycle advances. */
public final class PortAgeWeather {
private static final Map<PortAgeLevel, Integer> STATES = new WeakHashMap<>();
private PortAgeWeather() {}
public static void onLevelTickPre(LevelTickEvent.Pre event) {
if (event.getLevel() instanceof PortAgeLevel level) apply(level);
}
static void apply(PortAgeLevel level) {
PortWeatherMode mode = level.weatherMode();
if (mode == PortWeatherMode.NORMAL) {
STATES.remove(level);
return;
}
WeatherData weather = level.getWeatherData();
if (mode == PortWeatherMode.FAST || mode == PortWeatherMode.SLOW) {
int state = (weather.isRaining() ? 1 : 0) | (weather.isThundering() ? 2 : 0);
Integer previous = STATES.put(level, state);
if (previous != null && previous == state) return;
// Keep persisted timers on reload; rescale only when a cycle changes.
if (previous == null && weather.getRainTime() > 0) return;
int scale = mode == PortWeatherMode.FAST ? 1 : 4;
int duration = 6000 * scale + level.getRandom().nextInt(
(weather.isRaining() ? 6000 : 84000) * scale);
set(weather, duration, weather.isRaining(), weather.isThundering());
} else {
STATES.remove(level);
boolean rain = mode != PortWeatherMode.OFF;
boolean thunder = mode == PortWeatherMode.STORM;
if (weather.isRaining() != rain || weather.isThundering() != thunder
|| weather.getRainTime() < 200 || weather.getThunderTime() < 200
|| weather.getClearWeatherTime() != 0) set(weather, 6000, rain, thunder);
}
}
private static void set(WeatherData data, int duration, boolean rain, boolean thunder) {
data.setClearWeatherTime(0);
data.setRainTime(duration);
data.setThunderTime(duration);
data.setRaining(rain);
data.setThundering(thunder);
}
}
@@ -0,0 +1,99 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
/** Disposable fixture for atomic collection imports and real Binder assembly. */
final class PortBinderCollectionProbe {
private static final BlockPos BINDER = new BlockPos(0, 64, 0);
private static final BlockPos CHEST = new BlockPos(1, 64, 0);
private static final List<Identifier> EXPECTED = List.of(id("weather/off"), id("lighting/bright"),
id("terrain/flat"), id("biome/single"));
private PortBinderCollectionProbe() {}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static ItemStack page(String path) { return PageItem.createSymbolPage(id(path)); }
private static ItemStack collection(boolean folder, List<ItemStack> pages) {
ItemStack result = new ItemStack(folder ? Rivencraft.COLLATION_FOLDER.get() : Rivencraft.NOTEBOOK.get());
result.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
return result;
}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(BINDER, Rivencraft.BOOK_BINDER.get().defaultBlockState(), 3);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
var binder = (PortBookBinderBlockEntity) level.getBlockEntity(BINDER);
var chest = (Container) level.getBlockEntity(CHEST);
ItemStack folder = collection(true, List.of(page("weather/off"), ItemStack.EMPTY, page("lighting/bright")));
ItemStack before = folder.copy();
for (int i = 0; i < PortBookBinderBlockEntity.PAGE_COUNT; i++)
binder.setItem(PortBookBinderBlockEntity.FIRST_PAGE + i, page("terrain/flat"));
if (!binder.importCollection(folder, 0).isEmpty() || binder.nextPageIndex() != 252
|| !ItemStack.isSameItemSameComponents(before, folder)
|| binder.symbolPages().stream().anyMatch(symbol -> !symbol.equals(id("terrain/flat"))))
throw new IllegalStateException("Full Binder import was not atomic");
binder.clearSymbolPages();
binder.setItem(PortBookBinderBlockEntity.PANEL, PageItem.createLinkPanelPage());
if (!binder.importCollection(collection(true, List.of(page("weather/off"), PageItem.createLinkPanelPage())), 0).isEmpty()
|| binder.hasSymbolPages() || binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
throw new IllegalStateException("Conflicting Link Panel import was not atomic");
binder.setItem(PortBookBinderBlockEntity.PANEL, ItemStack.EMPTY);
if (!binder.importCollection(folder, -1).isEmpty() || !binder.importCollection(folder, 1).isEmpty())
throw new IllegalStateException("Invalid collection insertion point accepted");
binder.insertPage(0, page("terrain/flat"));
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("5225a1ed-5ead-44b5-a63c-c78e7dca86f5"), "RivenBinderProbe"));
player.setPos(0.5, 64, 2.5);
var menu = new PortBookBinderMenu(1, player.getInventory(), binder);
try {
menu.setCarried(folder);
if (!menu.clickMenuButton(player, PortBookBinderMenu.STRIP_CLICK_BASE + 1)
|| menu.getCarried().get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0
|| !binder.symbolPages().equals(EXPECTED.subList(0, 3)))
throw new IllegalStateException("Cursor collection order/empty-cover regression");
chest.setItem(0, menu.getCarried().copy());
menu.setCarried(ItemStack.EMPTY);
player.getInventory().setItem(9, collection(false, List.of(page("biome/single"))));
menu.quickMoveStack(player, PortBookBinderBlockEntity.SIZE);
ItemStack emptied = player.getInventory().getItem(9);
if (!emptied.is(Rivencraft.NOTEBOOK.get())
|| emptied.get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0
|| !binder.symbolPages().equals(EXPECTED))
throw new IllegalStateException("Shift collection import regression");
chest.setItem(1, emptied.copy());
binder.setItem(PortBookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(PortBookBinderBlockEntity.PANEL, PageItem.createLinkPanelPage());
menu.assemble(player, "Collection Fixture104");
ItemStack book = menu.getCarried();
if (!book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) || !EXPECTED.equals(book.get(Rivencraft.BOOK_SYMBOLS.get()))
|| !book.has(Rivencraft.AGE_BOOK_ID.get()) || binder.hasSymbolPages()
|| !binder.getItem(PortBookBinderBlockEntity.COVER).isEmpty()
|| !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
throw new IllegalStateException("Collection-backed Binder assembly regression");
chest.setItem(2, book.copy());
menu.setCarried(ItemStack.EMPTY);
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
if (!chest.getItem(0).is(Rivencraft.COLLATION_FOLDER.get()) || !chest.getItem(1).is(Rivencraft.NOTEBOOK.get()))
throw new IllegalStateException("Empty collection covers were not preserved");
for (int i = 0; i < 2; i++) if (chest.getItem(i).get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0)
throw new IllegalStateException("Imported pages remained in a collection");
if (!EXPECTED.equals(chest.getItem(2).get(Rivencraft.BOOK_SYMBOLS.get())))
throw new IllegalStateException("Authored book page order was not preserved");
}
}
@@ -0,0 +1,31 @@
package net.dnorberg.rivencraft.age;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.Registries;
import net.minecraft.data.worldgen.material.EndMaterialRules;
import net.minecraft.data.worldgen.material.NetherMaterialRules;
import net.minecraft.world.level.biome.Biomes;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.material.MaterialRules;
/** Root AgeDimensionService.withBiomeMaterialRules adapted to 26.3's rule registry. */
final class PortBiomeMaterialRules {
private PortBiomeMaterialRules() {}
static Holder<NoiseGeneratorSettings> apply(HolderLookup.Provider registries, NoiseGeneratorSettings base) {
var biomes = registries.lookupOrThrow(Registries.BIOME);
var rules = registries.lookupOrThrow(Registries.MATERIAL_RULE);
var materials = MaterialRules.sequence(
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.THE_END, Biomes.END_HIGHLANDS,
Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS),
MaterialRules.getRule(rules, EndMaterialRules.END)),
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.NETHER_WASTES, Biomes.SOUL_SAND_VALLEY,
Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS),
MaterialRules.getRule(rules, NetherMaterialRules.NETHER)),
base.materialRule().value());
return Holder.direct(new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), base.defaultFluid(),
base.noiseRouter(), Holder.direct(materials), base.spawnTarget(), base.seaLevel(), base.disableMobGeneration(),
base.aquifers(), base.useLegacyRandomSource(), base.debugFunctions()));
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import net.minecraft.resources.Identifier;
/** Frozen biome choice; Native may carry an explicit Cave pool. Repeats retain weight. */
public record PortBiomePlan(Distribution distribution, List<Identifier> biomes) {
public enum Distribution { NATIVE, SINGLE, TILED, GRID, TINY, SMALL, MEDIUM, LARGE, HUGE }
public static final Codec<PortBiomePlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.xmap(Distribution::valueOf, Distribution::name).fieldOf("distribution")
.forGetter(PortBiomePlan::distribution),
Identifier.CODEC.listOf().fieldOf("biomes").forGetter(PortBiomePlan::biomes)
).apply(instance, PortBiomePlan::new));
public PortBiomePlan {
biomes = List.copyOf(biomes);
if (distribution == null || distribution == Distribution.SINGLE && biomes.size() != 1
|| distribution != Distribution.NATIVE && distribution != Distribution.SINGLE && biomes.size() < 2)
throw new IllegalArgumentException("Invalid authored biome distribution");
}
}
@@ -0,0 +1,78 @@
package net.dnorberg.rivencraft.age;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.QuartPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.BiomeTags;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.CarvingMask;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.LegacyRandomSource;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.WorldGenerationContext;
import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.levelgen.carver.WorldCarver;
/** Authored Caves/Ravines, independent of the terrain page, with 1.21.1 source seeds. */
public final class PortCarverPages {
public static final Identifier CAVES = Identifier.fromNamespaceAndPath("rivencraft", "feature/caves");
public static final Identifier RAVINES = Identifier.fromNamespaceAndPath("rivencraft", "feature/ravines");
private static final long CAVE_SALT = 0x4341564553464541L;
private static final long RAVINE_SALT = 0x524156494E45534CL;
private PortCarverPages() {}
public static boolean supports(Identifier page) { return CAVES.equals(page) || RAVINES.equals(page); }
public static void validateCounts(List<Identifier> pages) {
Map<Identifier, Integer> counts = new HashMap<>();
for (Identifier page : pages) if (!supports(page) || counts.merge(page, 1, Integer::sum) > 32)
throw new IllegalArgumentException("Carver plan requires Caves/Ravines pages, at most 32 per type");
}
public static void validateRegistry(List<Identifier> pages, HolderLookup.Provider registries) {
if (pages.stream().noneMatch(PortCarverPages::supports)) return;
var carvers = registries.lookupOrThrow(Registries.CARVER);
for (String name : pages.contains(CAVES) ? List.of("cave", "nether_cave", "canyon") : List.of("canyon")) {
if (name.equals("canyon") && !pages.contains(RAVINES)) continue;
carvers.get(ResourceKey.create(Registries.CARVER, Identifier.withDefaultNamespace(name)))
.orElseThrow(() -> new IllegalArgumentException("Missing authored carver minecraft:" + name));
}
}
public static void carve(ChunkGenerator generator, ChunkAccess chunk, RandomState randomState,
HolderLookup.Provider registries, List<Identifier> pages, long seed, CarvingMask mask) {
if (pages.isEmpty()) return;
var registry = registries.lookupOrThrow(Registries.CARVER);
var biomes = generator.getBiomeSource().createUncachedResolver(randomState);
var context = new WorldGenerationContext(generator, chunk.getHeightAccessorForGeneration());
var random = new WorldgenRandom(new LegacyRandomSource(0L));
ChunkPos target = chunk.getPos();
// 1.21.1 repeats identical seeds for duplicate Caves/Ravines pages.
// Their union is identical; avoid replaying the same mask up to 32 times.
for (Identifier page : pages.stream().distinct().toList()) {
boolean ravine = page.equals(RAVINES);
WorldCarver overworld = registry.getOrThrow(ResourceKey.create(Registries.CARVER,
Identifier.withDefaultNamespace(ravine ? "canyon" : "cave"))).value();
WorldCarver nether = ravine ? null : registry.getOrThrow(ResourceKey.create(Registries.CARVER,
Identifier.withDefaultNamespace("nether_cave"))).value();
for (int dx = -8; dx <= 8; dx++) {
for (int dz = -8; dz <= 8; dz++) {
ChunkPos source = new ChunkPos(target.x() + dx, target.z() + dz);
boolean isNether = biomes.getNoiseBiome(QuartPos.fromBlock(source.getMinBlockX()), 0,
QuartPos.fromBlock(source.getMinBlockZ())).is(BiomeTags.IS_NETHER);
if (ravine && isNether) continue;
WorldCarver carver = isNether ? nether : overworld;
random.setLargeFeatureSeed(seed ^ (ravine ? RAVINE_SALT : CAVE_SALT), source.x(), source.z());
if (carver.isStartChunk(random)) carver.carve(context, random, target, source, mask);
}
}
}
}
}
@@ -0,0 +1,96 @@
package net.dnorberg.rivencraft.age;
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;
/** One frozen authored orbit, using the same angular motion as 1.21.1 Body. */
public record PortCelestialBody(int kind, double period, int stoppedTime, long originTick,
int phaseTick, boolean clockwise, int bearing, float size, int color) {
public static final Codec<PortCelestialBody> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.intRange(1, 2).fieldOf("kind").forGetter(PortCelestialBody::kind),
Codec.doubleRange(0, 2).fieldOf("period").forGetter(PortCelestialBody::period),
Codec.intRange(0, 23999).fieldOf("stopped_time").forGetter(PortCelestialBody::stoppedTime),
Codec.LONG.fieldOf("origin_tick").forGetter(PortCelestialBody::originTick),
Codec.intRange(0, 23999).fieldOf("phase_tick").forGetter(PortCelestialBody::phaseTick),
Codec.BOOL.fieldOf("clockwise").forGetter(PortCelestialBody::clockwise),
Codec.intRange(-1, 359).fieldOf("bearing").forGetter(PortCelestialBody::bearing),
Codec.floatRange(0.25F, 3F).fieldOf("size").forGetter(PortCelestialBody::size),
Codec.intRange(-1, 0xffffff).fieldOf("color").forGetter(PortCelestialBody::color)
).apply(instance, PortCelestialBody::new));
public static final StreamCodec<RegistryFriendlyByteBuf, PortCelestialBody> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, PortCelestialBody::kind, ByteBufCodecs.DOUBLE, PortCelestialBody::period,
ByteBufCodecs.VAR_INT, PortCelestialBody::stoppedTime, ByteBufCodecs.VAR_LONG, PortCelestialBody::originTick,
ByteBufCodecs.VAR_INT, PortCelestialBody::phaseTick, ByteBufCodecs.BOOL, PortCelestialBody::clockwise,
ByteBufCodecs.INT, PortCelestialBody::bearing, ByteBufCodecs.FLOAT, PortCelestialBody::size,
ByteBufCodecs.INT, PortCelestialBody::color, PortCelestialBody::new);
public PortCelestialBody {
if (kind != PortDayCycle.SUN && kind != PortDayCycle.MOON || !Double.isFinite(period)
|| period < 0 || period > 2 || stoppedTime < 0 || stoppedTime >= 24000
|| phaseTick < 0 || phaseTick >= 24000 || bearing < -1 || bearing > 359
|| !Float.isFinite(size) || size < 0.25F || size > 3 || color < -1 || color > 0xffffff)
throw new IllegalArgumentException("Invalid celestial body");
}
public static PortCelestialBody of(PortDayCycle cycle, int kind, int color, boolean pairedMoon) {
int offset = pairedMoon ? 12000 : 0;
return new PortCelestialBody(kind, cycle.period(), (cycle.stoppedTime() + offset) % 24000,
cycle.originTick(), (cycle.phaseTick() + offset) % 24000, cycle.clockwise(),
cycle.bearing(), pairedMoon ? 1F : cycle.sunSize(), color);
}
public float angle(double nativeTicks) {
double time = period == 0 ? stoppedTime
: (clockwise ? 1 : -1) * (nativeTicks - originTick) / period + phaseTick;
return (float) (((time - 6000D) % 24000D) * (360D / 24000D));
}
public float light(long nativeTicks) {
return Math.max(0F, (float) Math.cos(Math.toRadians(angle(nativeTicks))))
* (kind == PortDayCycle.MOON ? 0.18F : 1F);
}
public net.minecraft.world.phys.Vec3 direction(double ticks) {
double orbit = Math.toRadians(angle(ticks)), axis = Math.toRadians(bearing < 0 ? -90 : bearing);
double lateral = Math.sin(orbit);
return new net.minecraft.world.phys.Vec3(Math.sin(axis) * lateral, Math.cos(orbit), Math.cos(axis) * lateral);
}
/** Root AgeSkyProfile.eclipseFor: contact uses the central quarter-size disc, not its halo. */
public float eclipse(java.util.List<PortCelestialBody> bodies, double ticks) {
float strongest = 0;
var direction = direction(ticks);
double radius = Math.atan((kind == PortDayCycle.SUN ? 30D : 20D) * size * 0.25D / 100D);
for (var other : bodies) {
if (other.kind == kind) continue;
double separation = Math.acos(Math.max(-1D, Math.min(1D, direction.dot(other.direction(ticks)))));
double contact = radius + Math.atan((other.kind == PortDayCycle.SUN ? 30D : 20D) * other.size * 0.25D / 100D);
strongest = Math.max(strongest, (float) Math.max(0D, 1D - separation / contact));
}
return strongest;
}
public static float eclipseAt(java.util.List<PortCelestialBody> bodies, double ticks) {
float strongest = 0;
for (var body : bodies) if (body.kind == PortDayCycle.SUN && body.direction(ticks).y > 0D)
strongest = Math.max(strongest, body.eclipse(bodies, ticks));
return strongest;
}
/** Shared client/server-checkable RGB rule; alpha is handled by the renderer. */
public int eclipseColor(float eclipse) {
int rgb = color < 0 ? 0xffffff : color;
float corona = kind == PortDayCycle.SUN ? (float) Math.pow(eclipse, 8D) * 0.8F : 0F;
int red = channel(rgb >> 16 & 255, eclipse, corona);
int green = channel(rgb >> 8 & 255, eclipse, corona);
int blue = channel(rgb & 255, eclipse, corona);
return red << 16 | green << 8 | blue;
}
private int channel(int value, float eclipse, float corona) {
return Math.round(kind == PortDayCycle.MOON ? value * (1F - eclipse) : value + (255 - value) * corona);
}
}
@@ -0,0 +1,109 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.SymbolPortfolioItem;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
/** Disposable-world boundary, booster-use and persistence checks. */
final class PortCollectionExtrasProbe {
private static final BlockPos CHEST = new BlockPos(0, 64, 0);
private PortCollectionExtrasProbe() {}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static NotebookContents contents(ItemStack stack) { return stack.get(Rivencraft.NOTEBOOK_CONTENTS.get()); }
private static List<Identifier> symbols(ItemStack collection) {
return contents(collection).pages().stream().map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList();
}
private static void folder(ItemStack stack) {
if (!stack.is(Rivencraft.COLLATION_FOLDER.get()) || contents(stack).pageCount() != 16
|| symbols(stack).stream().anyMatch(id -> !PortSymbolPages.sealedCandidateIds().contains(id)))
throw new IllegalStateException("Sealed Notebook page pool/count regression");
}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
var chest = (Container) level.getBlockEntity(CHEST);
var pages = new java.util.ArrayList<ItemStack>();
for (int i = 0; i < 253; i++) pages.add(PageItem.createSymbolPage(id(i % 2 == 0 ? "terrain/flat" : "weather/off")));
var volumes = SymbolPortfolioItem.createVolumes(id("feature"), pages);
if (volumes.size() != 2 || contents(volumes.get(0)).pageCount() != 252
|| contents(volumes.get(1)).pageCount() != 1
|| !volumes.stream().flatMap(volume -> symbols(volume).stream()).toList()
.equals(pages.stream().map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList())
|| !SymbolPortfolioItem.createVolumes(id("feature"), List.of()).isEmpty())
throw new IllegalStateException("Portfolio volume boundary/order regression");
chest.setItem(0, volumes.get(0));
chest.setItem(1, volumes.get(1));
var actual = net.dnorberg.rivencraft.registry.ModCreativeTabs.creativeSymbolPortfolios(level.registryAccess());
var allPages = PortSymbolPages.categories().stream().flatMap(category -> PortSymbolPages.pages(category, level.registryAccess()).stream())
.map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList();
if (!actual.stream().flatMap(volume -> symbols(volume).stream()).toList().equals(allPages))
throw new IllegalStateException("Creative portfolios omit/duplicate/reorder pages");
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("790b69dd-b62b-45be-9017-caf16515169a"), "RivenExtrasProbe"));
player.setPos(0.5, 64, 2.5);
try {
player.getAbilities().instabuild = false;
player.getRandom().setSeed(105);
var first = net.dnorberg.rivencraft.item.SealedNotebookItem.open(player);
player.getRandom().setSeed(105);
var repeat = net.dnorberg.rivencraft.item.SealedNotebookItem.open(player);
folder(first);
if (!symbols(first).equals(symbols(repeat))) throw new IllegalStateException("Booster seeded selection changed");
chest.setItem(2, first);
for (var hand : InteractionHand.values()) {
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
player.setItemInHand(hand, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get()));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, hand);
folder(player.getItemInHand(hand));
}
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get(), 2));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
if (player.getMainHandItem().getCount() != 1) throw new IllegalStateException("Booster stack consumption regression");
player.getAbilities().instabuild = true;
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
if (!player.getMainHandItem().is(Rivencraft.SEALED_NOTEBOOK.get()) || player.getMainHandItem().getCount() != 1)
throw new IllegalStateException("Creative booster consumed its input");
player.getAbilities().instabuild = false;
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get(), 2));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
var dropped = level.getEntitiesOfClass(ItemEntity.class, new AABB(-3, 60, -3, 4, 70, 6),
entity -> entity.getItem().is(Rivencraft.COLLATION_FOLDER.get()));
if (dropped.size() != 1 || player.getMainHandItem().getCount() != 1)
throw new IllegalStateException("Full-inventory booster delivery regression");
folder(dropped.getFirst().getItem());
dropped.getFirst().discard();
} finally {
player.getAbilities().instabuild = false;
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
}
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
if (!chest.getItem(0).is(Rivencraft.SYMBOL_PORTFOLIO.get()) || contents(chest.getItem(0)).pageCount() != 252
|| !chest.getItem(1).is(Rivencraft.SYMBOL_PORTFOLIO.get()) || contents(chest.getItem(1)).pageCount() != 1)
throw new IllegalStateException("Portfolio persistence regression");
folder(chest.getItem(2));
}
}
@@ -0,0 +1,78 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.AmethystClusterBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/** 26.3 port of the 1.21.1 surface crystal mound with budding hearts and seeded buds. */
final class PortCrystalFormationsFeature {
private static final long SALT = 0x4352595354414C53L;
private static final int CHANCE = 15;
private PortCrystalFormationsFeature() {}
record Site(int x, int z, long seed) {}
static Site site(long ageSeed, ChunkPos chunk, int occurrence) {
long seed = mix(ageSeed ^ SALT ^ (long) chunk.x() * 341873128712L
^ (long) chunk.z() * 132897987541L ^ (long) occurrence * 0x9E3779B97F4A7C15L);
RandomSource random = RandomSource.create(seed);
if (random.nextInt(CHANCE) != 0) return null;
return new Site(chunk.getMinBlockX() + 5 + random.nextInt(6),
chunk.getMinBlockZ() + 5 + random.nextInt(6), seed);
}
static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, int occurrence) {
Site site = site(ageSeed, chunk.getPos(), occurrence);
if (site == null) return;
RandomSource random = RandomSource.create(site.seed() ^ 0x42554444494E474CL);
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, site.x(), site.z());
if (y <= level.getMinY() + 1 || y + 4 >= level.getMaxY()) return;
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
int x = site.x() + dx;
int z = site.z() + dz;
if (Math.abs(level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) - y) > 1) return;
BlockState floor = level.getBlockState(pos.set(x, y - 1, z));
if (!floor.isFaceSturdy(level, pos, Direction.UP) || !floor.getFluidState().isEmpty()
|| floor.hasBlockEntity()) return;
for (int h = 0; h < 4; h++) {
BlockState existing = level.getBlockState(pos.set(x, y + h, z));
if (!existing.isAir() && !existing.canBeReplaced()) return;
}
}
}
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
boolean center = dx == 0 && dz == 0;
int height = center ? 3 : random.nextInt(3) == 0 ? 2 : 1;
for (int h = 0; h < height; h++) {
boolean budding = h == height - 1 && (center || random.nextBoolean());
level.setBlock(pos.set(site.x() + dx, y + h, site.z() + dz),
budding ? Rivencraft.BUDDING_CRYSTAL.get().defaultBlockState()
: Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 2);
}
if (level.getBlockState(pos.set(site.x() + dx, y + height - 1, site.z() + dz))
.is(Rivencraft.BUDDING_CRYSTAL) && random.nextBoolean()) {
level.setBlock(pos.set(site.x() + dx, y + height, site.z() + dz),
Rivencraft.SMALL_CRYSTAL_BUD.get().defaultBlockState()
.setValue(AmethystClusterBlock.FACING, Direction.UP), 2);
}
}
}
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,342 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.minecraft.core.Holder;
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.world.clock.ClockInstance;
import net.minecraft.world.clock.ClockManager;
import net.minecraft.world.clock.WorldClock;
import net.minecraft.world.clock.WorldClocks;
import net.minecraft.world.level.Level;
/** Authored native celestial controls. Never mutates the shared clock. */
public record PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize, int bodyMode,
List<PortCelestialBody> bodies) {
public static final int PAIRED = 0, SUN = 1, MOON = 2, NONE = 3;
public static final PortDayCycle NATIVE = new PortDayCycle(false, 1, 0);
public static final Identifier NORMAL_TIME = Identifier.fromNamespaceAndPath("rivencraft", "celestial/normal_time");
public static final Codec<PortDayCycle> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.BOOL.fieldOf("authored").forGetter(PortDayCycle::authored),
Codec.doubleRange(0, 2).fieldOf("period").forGetter(PortDayCycle::period),
Codec.intRange(0, 23999).fieldOf("stopped_time").forGetter(PortDayCycle::stoppedTime),
Codec.LONG.optionalFieldOf("origin_tick", 0L).forGetter(PortDayCycle::originTick),
Codec.intRange(0, 23999).optionalFieldOf("phase_tick", 0).forGetter(PortDayCycle::phaseTick),
Codec.BOOL.optionalFieldOf("clockwise", true).forGetter(PortDayCycle::clockwise),
Codec.intRange(-1, 359).optionalFieldOf("bearing", -1).forGetter(PortDayCycle::bearing),
Codec.floatRange(0.25F, 3F).optionalFieldOf("sun_size", 1F).forGetter(PortDayCycle::sunSize),
Codec.intRange(0, 3).optionalFieldOf("body_mode", PAIRED).forGetter(PortDayCycle::bodyMode),
PortCelestialBody.CODEC.listOf().optionalFieldOf("bodies", List.of()).forGetter(PortDayCycle::bodies)
).apply(instance, PortDayCycle::new));
public static final StreamCodec<RegistryFriendlyByteBuf, PortDayCycle> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, PortDayCycle::authored, ByteBufCodecs.DOUBLE, PortDayCycle::period,
ByteBufCodecs.VAR_INT, PortDayCycle::stoppedTime, ByteBufCodecs.VAR_LONG, PortDayCycle::originTick,
ByteBufCodecs.VAR_INT, PortDayCycle::phaseTick, ByteBufCodecs.BOOL, PortDayCycle::clockwise,
ByteBufCodecs.INT, PortDayCycle::bearing, ByteBufCodecs.FLOAT, PortDayCycle::sunSize,
ByteBufCodecs.VAR_INT, PortDayCycle::bodyMode,
PortCelestialBody.STREAM_CODEC.apply(ByteBufCodecs.list()), PortDayCycle::bodies, PortDayCycle::new);
private static volatile Map<Identifier, PortDayCycle> clientCycles = Map.of();
public PortDayCycle {
bodies = List.copyOf(bodies);
if (!Double.isFinite(period) || period < 0 || period > 2 || stoppedTime < 0 || stoppedTime >= 24000
|| phaseTick < 0 || phaseTick >= 24000 || bearing < -1 || bearing >= 360
|| !Float.isFinite(sunSize) || sunSize < 0.25F || sunSize > 3F
|| bodyMode < PAIRED || bodyMode > NONE
|| bodyMode == NONE && (!authored || !bodies.isEmpty()))
throw new IllegalArgumentException("Invalid day cycle");
if (bodies.stream().filter(body -> body.kind() == SUN).count() > 10
|| bodies.stream().filter(body -> body.kind() == MOON).count() > 10)
throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
}
public PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize, int bodyMode) {
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, bearing, sunSize, bodyMode, List.of());
}
public PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize) {
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, bearing, sunSize, PAIRED);
}
public PortDayCycle(boolean authored, double period, int stoppedTime) {
this(authored, period, stoppedTime, 0, 0, true);
}
public PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise) {
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, -1);
}
public PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing) {
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, bearing, 1F);
}
public static boolean isPeriod(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "period/full", "period/half", "period/double", "period/zero" -> true;
default -> false;
};
}
public static boolean isPhase(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "phase/rising", "phase/zenith", "phase/setting", "phase/nadir" -> true;
default -> false;
};
}
public static boolean isDirection(Identifier page) {
return page.getNamespace().equals("rivencraft") && (page.getPath().equals("direction/clockwise")
|| page.getPath().equals("direction/counterclockwise"));
}
public static boolean isAngle(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "angle/north", "angle/east", "angle/south", "angle/west" -> true;
default -> false;
};
}
public static boolean supports(Identifier page) {
return isController(page) || isPeriod(page) || isPhase(page) || isDirection(page) || isAngle(page) || isSize(page);
}
public static boolean isController(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "celestial/normal_time", "celestial/sun", "celestial/moon" -> true;
default -> false;
};
}
public static boolean isSize(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "size/small", "size/normal", "size/large" -> true;
default -> false;
};
}
public static boolean validWriting(List<Identifier> pages) {
int periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0, suns = 0, moons = 0;
for (Identifier page : pages) {
if (isPeriod(page) && ++periods > 1 || isPhase(page) && ++phases > 1
|| isDirection(page) && ++directions > 1 || isAngle(page) && ++angles > 1
|| isSize(page) && ++sizes > 1) return false;
if (isController(page)) {
if (!page.getPath().equals("celestial/moon")) suns++;
if (!page.getPath().equals("celestial/sun")) moons++;
if (suns > 10 || moons > 10) return false;
periods = phases = directions = angles = sizes = 0;
}
}
return periods + phases + directions + angles + sizes == 0;
}
public static PortDayCycle compile(List<Identifier> pages, long seed) {
return compile(pages, seed, 0);
}
/** Creation-only completion. Exact diagnostic fixtures can still use compile. */
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks) {
if (!validWriting(pages) || !PortAgeColors.validNumbers(pages))
throw new IllegalArgumentException("Invalid celestial modifier order");
if (pages.stream().anyMatch(PortDayCycle::isController)) return compile(pages, seed, nativeTicks);
// Match root AgeSkyProfile.random's count draw positions: sky style,
// mixed sky color, then independent 0..3 sun and moon counts.
var random = new Random(seed ^ 0x534B5950524F464CL);
random.nextInt(5);
randomColor(random);
int suns = random.nextInt(4), moons = random.nextInt(4);
var bodies = new java.util.ArrayList<PortCelestialBody>();
for (int i = 0; i < suns + moons; i++)
bodies.addAll(randomUnmodified(seed, i, nativeTicks, i < suns ? SUN : MOON).bodies);
if (bodies.isEmpty()) return new PortDayCycle(true, 1, 0, 0, 0, true, -1, 1F, NONE);
var first = bodies.getFirst();
return new PortDayCycle(true, first.period(), first.stoppedTime(), first.originTick(), first.phaseTick(),
first.clockwise(), first.bearing(), first.size(), first.kind(), bodies);
}
public static PortDayCycle compile(List<Identifier> pages, long seed, long nativeTicks) {
if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one of each celestial modifier before its controller; limit ten suns and ten moons");
if (pages.stream().filter(PortDayCycle::isController).count() <= 1)
return compileOne(pages, seed, nativeTicks, 0);
var bodies = new java.util.ArrayList<PortCelestialBody>();
PortDayCycle first = null;
int start = 0, ordinal = 0;
for (int i = 0; i < pages.size(); i++) if (isController(pages.get(i))) {
var cycle = compileOne(pages.subList(start, i + 1), seed, nativeTicks, ordinal++);
if (first == null) first = cycle;
// Prefix retains original page indices for seeded unnumbered colors.
int color = PortAgeColors.compile(pages.subList(0, i + 1), seed).celestial();
if (!cycle.bodies.isEmpty()) bodies.addAll(cycle.bodies);
else {
if (cycle.bodyMode != MOON) bodies.add(PortCelestialBody.of(cycle, SUN, color, false));
if (cycle.bodyMode != SUN) bodies.add(PortCelestialBody.of(cycle, MOON, color, cycle.bodyMode == PAIRED));
}
start = i + 1;
}
return new PortDayCycle(first.authored, first.period, first.stoppedTime, first.originTick,
first.phaseTick, first.clockwise, first.bearing, first.sunSize, first.bodyMode, bodies);
}
private static PortDayCycle compileOne(List<Identifier> pages, long seed, long nativeTicks, int ordinal) {
var controller = pages.stream().filter(PortDayCycle::isController).findFirst().orElse(null);
if (controller == null) return NATIVE;
int mode = controller.getPath().equals("celestial/sun") ? SUN
: controller.getPath().equals("celestial/moon") ? MOON : PAIRED;
if (mode != PAIRED && !hasBodyModifiers(pages)) return randomUnmodified(seed, ordinal, nativeTicks, mode);
double period = 1;
Integer phase = null;
boolean clockwise = true;
int bearing = -1;
float size = 1F;
for (int i = 0; i < pages.size(); i++) {
Identifier page = pages.get(i);
int number = i > 0 ? PortAgeColors.number(pages.get(i - 1)) : -1;
if (isPhase(page)) {
int degrees = number >= 0 ? number * 15 % 360 : switch (page.getPath()) {
case "phase/rising" -> 90;
case "phase/zenith" -> 180;
case "phase/setting" -> 270;
default -> 0;
};
// Same conversion as 1.21.1 AgeDimensionService.applyInitialCelestialPhase:
// the written angle starts at Nadir, while native time zero is sunrise.
phase = (degrees * 24000 / 360 + 18000) % 24000;
} else if (isAngle(page)) {
bearing = number >= 0 ? number * 15 % 360 : switch (page.getPath()) {
case "angle/east" -> 90;
case "angle/south" -> 180;
case "angle/west" -> 270;
default -> 0;
};
} else if (isSize(page)) {
size = number >= 0 ? (float) (0.25D + number * (2.75D / 24D)) : switch (page.getPath()) {
case "size/small" -> 0.5F;
case "size/large" -> 2F;
default -> 1F;
};
} else if (isDirection(page)) {
clockwise = page.getPath().equals("direction/clockwise");
} else if (isPeriod(page)) period = number >= 0 ? number * 0.08 : switch (page.getPath()) {
case "period/half" -> 0.5;
case "period/double" -> 2;
case "period/zero" -> 0;
default -> 1;
};
}
int stopped = period == 0 ? phase != null ? phase
: new Random(seed ^ 0x4441594359434C45L).nextInt(24000) : 0;
return new PortDayCycle(true, period, stopped, phase != null ? nativeTicks : 0,
phase == null ? 0 : phase, clockwise, bearing, size, mode);
}
private static boolean hasBodyModifiers(List<Identifier> pages) {
boolean color = false, controls = false;
for (var page : pages) {
if (isController(page)) return controls || color;
if (PortAgeColors.isColor(page)) color = true;
else if (PortSkyStyle.forPage(page) != null || page.equals(PortAgeColors.AMBIENT)) color = false;
if (isPeriod(page) || isPhase(page) || isDirection(page) || isAngle(page) || isSize(page)) controls = true;
}
return false;
}
private static PortDayCycle randomUnmodified(long seed, int ordinal, long nativeTicks, int mode) {
// Independent per-controller stream: unrelated terrain/sky/weather pages
// cannot reshuffle the bodies. Use root random-Age bounds, not native defaults.
var random = new Random(seed ^ 0x43454C4553544941L ^ (ordinal + 1L) * 0x9E3779B97F4A7C15L);
double period = random.nextInt(7) == 0 ? 0D : 0.2D + random.nextDouble() * 1.8D;
int phase = random.nextInt(24000), bearing = random.nextInt(360);
float size = 0.75F + random.nextFloat();
boolean clockwise = random.nextBoolean();
int color = randomColor(random);
var cycle = new PortDayCycle(true, period, period == 0 ? phase : 0, nativeTicks,
phase, clockwise, bearing, size, mode);
return new PortDayCycle(true, period, cycle.stoppedTime, nativeTicks, phase, clockwise, bearing, size,
mode, List.of(PortCelestialBody.of(cycle, mode, color, false)));
}
private static int randomColor(Random random) {
return PortAgeColors.randomColor(random, false);
}
public boolean singleBody() { return authored && (bodyMode == SUN || bodyMode == MOON); }
public boolean customLight() { return singleBody() || !bodies.isEmpty(); }
/** Linear authored orbit, as in 1.21.1 Body; zero degrees is Zenith. */
public float bodyAngle(long nativeTicks) {
return (float) (((time(nativeTicks) - 6000D) % 24000D) * (360D / 24000D));
}
public float bodyLight(long nativeTicks) {
if (bodyMode == NONE) return 0;
if (!bodies.isEmpty()) {
float sun = 0, moon = 0;
for (var body : bodies) {
if (body.kind() == SUN) sun += body.light(nativeTicks) * (1F - 0.85F * body.eclipse(bodies, nativeTicks));
else moon += body.light(nativeTicks);
}
return Math.min(1F, sun > 0 ? sun : moon);
}
float altitude = Math.max(0F, (float) Math.cos(Math.toRadians(bodyAngle(nativeTicks))));
return altitude * (bodyMode == MOON ? 0.18F : 1F);
}
public long ticks(long nativeTicks) {
if (!authored) return nativeTicks;
return (long) Math.floor(time(nativeTicks));
}
private double time(long nativeTicks) {
return !authored ? nativeTicks : period == 0 ? stoppedTime
: (clockwise ? 1 : -1) * ((double) nativeTicks - originTick) / period + phaseTick;
}
public static long nativeTicks(Level level) {
return level.clockManager().getInstance(level.registryAccess().lookupOrThrow(
net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(WorldClocks.OVERWORLD)).totalTicks();
}
public static void updateClient(Map<Identifier, PortDayCycle> cycles) { clientCycles = Map.copyOf(cycles); }
public static PortDayCycle forLevel(Level level) {
if (level instanceof PortAgeLevel age) return age.dayCycle();
return level.isClientSide() ? clientCycles.getOrDefault(level.dimension().identifier(), NATIVE) : NATIVE;
}
/** Timelines keep this manager; profiles are read lazily after level construction/catalog sync. */
public static ClockManager timelines(Level level, ClockManager nativeManager) {
Map<Holder<WorldClock>, ClockInstance> wrappers = new HashMap<>();
return definition -> {
if (!definition.is(WorldClocks.OVERWORLD)) return nativeManager.getInstance(definition);
return wrappers.computeIfAbsent(definition, ignored -> new ClockInstance() {
private ClockInstance source() { return nativeManager.getInstance(definition); }
public long totalTicks() { return forLevel(level).ticks(source().totalTicks()); }
public float partialTick() {
PortDayCycle cycle = forLevel(level);
if (!cycle.authored) return source().partialTick();
if (cycle.period == 0) return 0;
double time = cycle.time(source().totalTicks());
return (float) (time - Math.floor(time)
+ (cycle.clockwise ? 1 : -1) * source().partialTick() / cycle.period);
}
public float rate() {
PortDayCycle cycle = forLevel(level);
return !cycle.authored ? source().rate() : cycle.period == 0 ? 0
: (float) ((cycle.clockwise ? 1 : -1) * source().rate() / cycle.period);
}
public boolean isPaused() { return source().isPaused() || forLevel(level).authored && forLevel(level).period == 0; }
});
};
}
}
@@ -0,0 +1,90 @@
package net.dnorberg.rivencraft.age;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.tags.TagKey;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.feature.AbstractOreFeature;
import net.minecraft.world.level.levelgen.placement.FeaturePlacer;
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
import net.neoforged.neoforge.common.Tags;
/** Replays each eligible biome ore placement once per page, matching Rivencraft 1.21.1. */
public final class PortDenseOres {
public static final TagKey<PlacedFeature> EXTRA_ORES = TagKey.create(Registries.PLACED_FEATURE,
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "dense_ores"));
private PortDenseOres() {}
public static boolean hasOres(Holder<Biome> biome) {
return oreFeatures(biome).anyMatch(PortDenseOres::isOre);
}
/** Modern ore features carry their targets directly rather than an OreConfiguration. */
public static boolean isOre(Holder<PlacedFeature> holder) {
if (holder.is(EXTRA_ORES)) return true;
if (!(holder.value().feature().value() instanceof AbstractOreFeature ore)) return false;
return ore.targetStates().stream().anyMatch(target -> isOreBlock(target.state()));
}
private static boolean isOreBlock(BlockState state) {
if (state.is(Tags.BlockItems.ORES.block())) return true;
String path = BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath();
return path.endsWith("_ore") || path.contains("_ore_");
}
private static java.util.stream.Stream<Holder<PlacedFeature>> oreFeatures(Holder<Biome> biome) {
return biome.value().getGenerationSettings().features().stream().flatMap(HolderSet::stream);
}
public static void place(WorldGenLevel level, ChunkAccess chunk, ChunkGenerator generator,
long ageSeed, int count) {
if (count == 0) return;
Set<Holder<Biome>> biomes = new HashSet<>();
ChunkPos.rangeClosed(chunk.getPos(), 1).forEach(pos -> {
for (var section : level.getChunk(pos.x(), pos.z()).getSections())
section.getBiomes().getAll(biomes::add);
});
biomes.retainAll(generator.getBiomeSource().possibleBiomes());
// Collect once per chunk, not once per repeated page. Stable registry-ID
// order and per-occurrence seeds retain the 1.21.1 placement sequence.
Map<Identifier, PlacedFeature> ores = new TreeMap<>();
for (Holder<Biome> biome : biomes) {
oreFeatures(biome).filter(PortDenseOres::isOre).forEach(feature ->
feature.unwrapKey().ifPresent(key -> ores.putIfAbsent(key.identifier(), feature.value())));
}
BlockPos origin = new BlockPos(chunk.getPos().getMinBlockX(), level.getMinY(),
chunk.getPos().getMinBlockZ());
FeaturePlacer placer = new FeaturePlacer(level, generator);
for (int occurrence = 0; occurrence < count; occurrence++) {
for (var entry : ores.entrySet()) {
long seed = mix(ageSeed ^ (long) origin.getX() * 341873128712L
^ (long) origin.getZ() * 132897987541L
^ (long) entry.getKey().hashCode() * 0x9E3779B97F4A7C15L
^ (long) occurrence * 0xD1B54A32D192ED03L);
placer.placeWithBiomeCheck(entry.getValue(), RandomSource.create(seed), origin);
}
}
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,81 @@
package net.dnorberg.rivencraft.age;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.PortHandheldTravel;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.world.level.storage.TagValueOutput;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
/** Disposable-world checks for book ownership and the new entity save format. */
final class PortDroppedBookProbe {
private PortDroppedBookProbe() {}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
var player = FakePlayerFactory.get(level, new com.mojang.authlib.GameProfile(
UUID.fromString("4db5c544-0a0c-421d-a128-c520bda64642"), "RivenBookProbe"));
player.setPos(0.5, 64, 0.5);
try {
for (var hand : InteractionHand.values()) {
ItemStack book = new ItemStack(hand == InteractionHand.MAIN_HAND
? Rivencraft.LINKING_BOOK.get() : Rivencraft.DESCRIPTIVE_BOOK.get());
book.set(DataComponents.CUSTOM_NAME, Component.literal("Dropped fixture " + hand));
book.set(Rivencraft.LINK_BOOK_TARGET.get(), new LinkBookTarget(
Identifier.fromNamespaceAndPath("minecraft", "overworld"), 12.25, 80.5, -42.75, 135, -20));
player.setItemInHand(hand, book);
String failure = PortHandheldTravel.travel(player, hand, book, () -> "expected failure");
if (!"expected failure".equals(failure) || player.getItemInHand(hand) != book)
throw new IllegalStateException("Failed travel consumed the book");
try {
PortHandheldTravel.travel(player, hand, book, () -> { throw new IllegalStateException("fixture"); });
throw new AssertionError("Fixture exception was swallowed");
} catch (IllegalStateException expected) {
if (!"fixture".equals(expected.getMessage()) || player.getItemInHand(hand) != book) throw expected;
}
java.util.function.Consumer<net.neoforged.neoforge.event.entity.EntityJoinLevelEvent> cancel = event -> {
if (event.getLevel() == level && event.getEntity() instanceof DroppedBookEntity) event.setCanceled(true);
};
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(cancel);
try {
failure = PortHandheldTravel.travel(player, hand, book, () -> null);
if (failure != null || player.getItemInHand(hand) != book)
throw new IllegalStateException("Rejected dropped entity consumed the book");
} finally { net.neoforged.neoforge.common.NeoForge.EVENT_BUS.unregister(cancel); }
failure = PortHandheldTravel.travel(player, hand, book, () -> null);
if (failure != null || !player.getItemInHand(hand).isEmpty())
throw new IllegalStateException("Successful travel retained the active book");
}
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
var books = level.getEntitiesOfClass(DroppedBookEntity.class, new AABB(-2, 60, -2, 3, 68, 3));
if (books.size() != 2) throw new IllegalStateException("Expected two dropped books, found " + books.size());
for (var entity : books) {
var output = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
entity.saveWithoutId(output);
var copy = new DroppedBookEntity(Rivencraft.DROPPED_BOOK.get(), level);
copy.load(TagValueInput.create(ProblemReporter.DISCARDING, level.registryAccess(), output.buildResult()));
if (!ItemStack.isSameItemSameComponents(copy.getItem(), entity.getItem())
|| copy.getAge() != -32768 || copy.getItem().getCount() != 1)
throw new IllegalStateException("Dropped book persistence regression");
var copied = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
copy.saveWithoutId(copied);
if (copied.buildResult().getIntOr("BookDecayTicks", -1)
!= output.buildResult().getIntOr("BookDecayTicks", -2))
throw new IllegalStateException("Dropped book decay counter was not preserved");
}
}
}
@@ -0,0 +1,28 @@
package net.dnorberg.rivencraft.age;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import net.minecraft.resources.Identifier;
/** Seed-completes missing environment controllers once, before an Age is saved. */
public record PortEnvironmentSelection(PortWeatherMode weather, PortLighting lighting, PortSkyStyle sky) {
public static PortEnvironmentSelection resolve(List<Identifier> pages, long seed, PortAgeTerrain terrain) {
PortLighting lighting = pages.stream().map(PortLighting::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x4C49474854494E47L), PortLighting.values()));
PortWeatherMode weather = terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF
: pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x574541544845525FL), PortWeatherMode.values()));
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed));
}
private static <T> T rankedDefault(Random random, T[] choices) {
// LegacyRankChoice: the first (Normal) option is rank 1, the rest rank 2.
// Its rank-1 weight is the complete rare group plus one. Derive this
// from the supported pool, rather than selecting unported weather.
int[] weights = new int[choices.length];
Arrays.fill(weights, 1);
weights[0] = choices.length;
return PortRandomAgeSelection.weighted(random, List.of(choices), weights);
}
}
@@ -0,0 +1,290 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries;
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.WorldGenRegion;
import net.minecraft.tags.TagKey;
import net.minecraft.tags.BiomeTags;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.blockpredicates.BlockPredicate;
import net.minecraft.world.level.levelgen.feature.BlockBlobFeature;
import net.minecraft.world.level.levelgen.feature.LakeFeature;
import net.minecraft.world.level.levelgen.feature.stateproviders.BlockStateProvider;
import net.minecraft.world.level.levelgen.placement.BiomeFilter;
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
import net.minecraft.world.level.levelgen.placement.RarityFilter;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import com.mojang.datafixers.util.Pair;
import net.minecraft.core.HolderSet;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.Block;
/** Delegates terrain generation and applies frozen Age-scoped features. */
public final class PortFeatureChunkGenerator extends ChunkGenerator {
private static final ResourceKey<PlacedFeature> FOREST_ROCK = ResourceKey.create(
Registries.PLACED_FEATURE, Identifier.withDefaultNamespace("forest_rock"));
private static final ResourceKey<PlacedFeature> DUNGEONS = ResourceKey.create(
Registries.PLACED_FEATURE, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "dungeons"));
private static final TagKey<Block> ROCK_GROUND = TagKey.create(
Registries.BLOCK, Identifier.withDefaultNamespace("forest_rock_can_place_on"));
public static final MapCodec<PortFeatureChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
ChunkGenerator.CODEC.fieldOf("base").forGetter(generator -> generator.base),
PortRockPlan.CODEC.optionalFieldOf("rocks").forGetter(generator -> Optional.ofNullable(generator.rocks)),
PortVegetationPlan.CODEC.optionalFieldOf("vegetation")
.forGetter(generator -> Optional.ofNullable(generator.vegetation)),
com.mojang.serialization.Codec.intRange(0, 32).optionalFieldOf("dungeons", 0)
.forGetter(generator -> generator.dungeons),
PortLakePlan.CODEC.optionalFieldOf("lakes")
.forGetter(generator -> Optional.ofNullable(generator.lakes)),
PortTerrainFeaturePlan.CODEC.optionalFieldOf("terrain_features")
.forGetter(generator -> Optional.ofNullable(generator.terrainFeatures)),
com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed)
).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed) ->
new PortFeatureChunkGenerator(base, rocks.orElse(null), vegetation.orElse(null),
dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed)));
private final ChunkGenerator base;
private final PortRockPlan rocks;
private final PortVegetationPlan vegetation;
private final int dungeons;
private final PortLakePlan lakes;
private final PortTerrainFeaturePlan terrainFeatures;
private final long ageSeed;
public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, long ageSeed) {
super(base.getBiomeSource(), base::getBiomeGenerationSettings);
this.base = base;
this.rocks = rocks;
this.vegetation = vegetation;
this.dungeons = dungeons;
this.lakes = lakes;
this.terrainFeatures = terrainFeatures;
this.ageSeed = ageSeed;
}
public ChunkGenerator base() { return base; }
public PortRockPlan rocks() { return rocks; }
public PortVegetationPlan vegetation() { return vegetation; }
public int dungeons() { return dungeons; }
public PortLakePlan lakes() { return lakes; }
public PortTerrainFeaturePlan terrainFeatures() { return terrainFeatures; }
@Override protected MapCodec<? extends ChunkGenerator> codec() { return CODEC; }
@Override public void validate() { base.validate(); }
@Override public ChunkPos getOrigin(RandomState random) { return base.getOrigin(random); }
@Override public java.util.Optional<Identifier> getTypeNameForDataFixer() { return base.getTypeNameForDataFixer(); }
@Override public CompletableFuture<ChunkAccess> createBiomes(RandomState random, Blender blender,
StructureManager structures, ChunkAccess chunk) {
return base.createBiomes(random, blender, structures, chunk);
}
@Override public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> sets,
RandomState random, long seed) {
if (controlsStructures()) {
var lookup = PortStructurePages.filtered(sets, terrainFeatures.structures(), getBiomeSource());
var planning = PortStructurePages.planningBiomes(getBiomeSource(), sets, terrainFeatures.structures());
if (base instanceof PortFlatChunkGenerator || base instanceof net.minecraft.world.level.levelgen.FlatLevelSource)
return ChunkGeneratorStructureState.createForFlat(random, seed, getOrigin(random), planning,
lookup.listElements().map(holder -> (Holder<StructureSet>) holder));
return ChunkGeneratorStructureState.createForNormal(random, seed, getOrigin(random), planning, lookup);
}
return base.createState(sets, random, seed);
}
private boolean controlsStructures() {
return terrainFeatures != null && (terrainFeatures.structureControl() || !terrainFeatures.structures().isEmpty());
}
@Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state,
StructureManager structures, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> dimension) {
if (controlsStructures()) super.createStructures(registries, state, structures, chunk, templates, dimension);
else base.createStructures(registries, state, structures, chunk, templates, dimension);
}
@Override public void createReferences(WorldGenLevel level, StructureManager structures, ChunkAccess chunk) {
base.createReferences(level, structures, chunk);
}
@Override public Pair<BlockPos, Holder<Structure>> findNearestMapStructure(ServerLevel level,
HolderSet<Structure> targets, BlockPos origin, int radius, boolean skipKnown) {
return base.findNearestMapStructure(level, targets, origin, radius, skipKnown);
}
@Override public void spawnOriginalMobs(WorldGenRegion level) { base.spawnOriginalMobs(level); }
@Override public int getSpawnHeight(LevelHeightAccessor height) { return base.getSpawnHeight(height); }
@Override public int getGenDepth() { return base.getGenDepth(); }
@Override public int getSeaLevel() { return base.getSeaLevel(); }
@Override public int getMinY() { return base.getMinY(); }
@Override public CompletableFuture<ChunkAccess> buildTerrain(ChunkAccess chunk, Blender blender,
RandomState random, StructureManager structures, BiomeManager biomes,
WorldGenRegion carverBiomeRegion, Set<Holder<Biome>> possibleBiomes) {
CompletableFuture<ChunkAccess> built = base.buildTerrain(
chunk, blender, random, structures, biomes, carverBiomeRegion, possibleBiomes);
if (terrainFeatures == null || !terrainFeatures.skylands() && terrainFeatures.choices().isEmpty()) return built;
return built.thenApply(result -> {
// Match 1.21.1: filter the base terrain once, then add the authored
// solid volumes, irrespective of the Skylands page's written position.
if (terrainFeatures.skylands()) PortSkylandsFeature.filter(result, ageSeed);
java.util.Map<Identifier, Integer> occurrences = new java.util.HashMap<>();
for (PortTerrainFeaturePlan.Choice choice : terrainFeatures.choices()) {
if (PortTerrainFeaturePlan.isSurface(choice.page())) continue;
int occurrence = occurrences.merge(choice.page(), 1, Integer::sum) - 1;
long featureSeed = ageSeed ^ (long) occurrence * 0x9E3779B97F4A7C15L;
BlockState material = BuiltInRegistries.BLOCK.getValue(choice.material()).defaultBlockState();
if (choice.page().equals(PortTerrainFeaturePlan.FLOATING_ISLANDS))
PortFloatingIslandFeature.generate(result, featureSeed, material);
else if (choice.page().equals(PortTerrainFeaturePlan.TENDRILS))
PortTendrilFeature.generate(result, featureSeed, material);
else if (choice.page().equals(PortTerrainFeaturePlan.SPHERES))
PortSpheresFeature.generate(result, featureSeed, material);
else throw new IllegalStateException("Unsupported terrain feature: " + choice.page());
}
return result;
});
}
@Override public int getBaseHeight(int x, int z, Heightmap.Types type,
LevelHeightAccessor height, RandomState random) {
return base.getBaseHeight(x, z, type, height, random);
}
@Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor height, RandomState random) {
return base.getBaseColumn(x, z, height, random);
}
@Override public void addDebugScreenInfo(List<String> info, RandomState random,
BlockPos pos, SamplerContext context) {
base.addDebugScreenInfo(info, random, pos, context);
}
@Override public void refreshFeaturesPerStep() {
base.refreshFeaturesPerStep();
super.refreshFeaturesPerStep();
}
@Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk,
StructureManager structures) {
base.applyBiomeDecoration(level, chunk, structures);
if (terrainFeatures != null) {
PortDenseOres.place(level, chunk, this, ageSeed, terrainFeatures.denseOres());
int spikeOccurrence = 0;
int obeliskOccurrence = 0;
int crystalOccurrence = 0;
for (PortTerrainFeaturePlan.Choice choice : terrainFeatures.choices()) {
if (!PortTerrainFeaturePlan.isSurface(choice.page())) continue;
BlockState material = BuiltInRegistries.BLOCK.getValue(choice.material()).defaultBlockState();
if (choice.page().equals(PortTerrainFeaturePlan.SPIKES))
PortSpikesFeature.generate(level, chunk, ageSeed, material, spikeOccurrence++);
else if (choice.page().equals(PortTerrainFeaturePlan.OBELISKS))
PortObelisksFeature.generate(level, chunk, ageSeed, material, obeliskOccurrence++);
else if (choice.page().equals(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS))
PortCrystalFormationsFeature.generate(level, chunk, ageSeed, crystalOccurrence++);
else throw new IllegalStateException("Unsupported surface feature: " + choice.page());
}
}
ChunkPos chunkPos = chunk.getPos();
long chunkSeed = ageSeed ^ (long) chunkPos.x() * 341873128712L
^ (long) chunkPos.z() * 132897987541L;
BlockPos origin = new BlockPos(chunkPos.getMinBlockX(), level.getMinY(), chunkPos.getMinBlockZ());
if (rocks != null) {
PlacedFeature vanilla = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
.getOrThrow(FOREST_ROCK).value();
var placement = vanilla.placement().stream()
.filter(modifier -> !(modifier instanceof BiomeFilter)).toList();
for (int occurrence = 0; occurrence < rocks.materials().size(); occurrence++) {
RandomSource random = RandomSource.create(chunkSeed
^ (long) occurrence * 0xD1B54A32D192ED03L);
BlockState material = BuiltInRegistries.BLOCK.getValue(
rocks.materials().get(occurrence)).defaultBlockState();
PlacedFeature authored = new PlacedFeature(Holder.direct(new BlockBlobFeature(
material, BlockPredicate.matchesTag(ROCK_GROUND))), placement);
authored.place(level, this, random, origin);
}
}
if (dungeons > 0) {
PlacedFeature authored = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
.getOrThrow(DUNGEONS).value();
for (int occurrence = 0; occurrence < dungeons; occurrence++) {
RandomSource random = RandomSource.create(chunkSeed
^ 0x44554E47454F4E53L ^ (long) occurrence * 0xD1B54A32D192ED03L);
authored.place(level, this, random, origin);
}
}
if (lakes != null) {
var registry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
java.util.Map<PortLakePlan.Choice, Integer> occurrences = new java.util.HashMap<>();
for (PortLakePlan.Choice choice : lakes.choices()) {
PlacedFeature vanilla = registry.getOrThrow(ResourceKey.create(
Registries.PLACED_FEATURE, PortLakePlan.placedFeature(choice.page()))).value();
if (!(vanilla.feature().value() instanceof LakeFeature lake))
throw new IllegalStateException("Lake placement no longer references a LakeFeature: " + choice.page());
BlockState fluid = BuiltInRegistries.BLOCK.getValue(choice.fluid()).defaultBlockState();
LakeFeature feature = new LakeFeature(BlockStateProvider.holderOf(fluid), lake.barrier(),
lake.canPlaceFeature(), lake.canReplaceWithAirOrFluid(), lake.canReplaceWithBarrier());
PlacedFeature authored = new PlacedFeature(Holder.direct(feature), vanilla.placement().stream()
.filter(modifier -> !(modifier instanceof BiomeFilter))
.map(modifier -> !PortLakePlan.isDeep(choice.page()) && modifier instanceof RarityFilter
? RarityFilter.onAverageOnceEvery(12) : modifier)
.toList());
int occurrence = occurrences.merge(choice, 1, Integer::sum) - 1;
RandomSource random = RandomSource.create(chunkSeed
^ (long) choice.hashCode() * 0x9E3779B97F4A7C15L
^ (long) occurrence * 0xD1B54A32D192ED03L);
authored.place(level, this, random, origin);
}
}
if (vegetation != null) {
int surface = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 8, 8);
Holder<Biome> biome = level.getBiome(new BlockPos(
chunkPos.getMinBlockX() + 8, surface, chunkPos.getMinBlockZ() + 8));
boolean surfaceBiome = !biome.is(BiomeTags.IS_END) && !PortRegistryPages.isCaveBiome(biome);
var registry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
List<BlockPos> mushroomHosts = null;
for (int occurrence = 0; occurrence < vegetation.pages().size(); occurrence++) {
Identifier page = vegetation.pages().get(occurrence);
RandomSource random = RandomSource.create(chunkSeed
^ (long) page.hashCode() * 0x9E3779B97F4A7C15L
^ (long) occurrence * 0xD1B54A32D192ED03L);
if (page.equals(PortVegetationPlan.SHELF_MUSHROOMS)) {
if (mushroomHosts == null) mushroomHosts = PortShelfMushrooms.findHosts(chunk);
PortShelfMushrooms.decorate(level, chunk, mushroomHosts, random);
continue;
}
if (!surfaceBiome) continue;
Identifier placedId = PortVegetationPlan.placedFeature(page);
PlacedFeature vanilla = registry.getOrThrow(
ResourceKey.create(Registries.PLACED_FEATURE, placedId)).value();
PlacedFeature authored = new PlacedFeature(vanilla.feature(), vanilla.placement().stream()
.filter(modifier -> !(modifier instanceof BiomeFilter)).toList());
authored.place(level, this, random, origin);
if (PortVegetationPlan.addsLogs(page)) mushroomHosts = null;
}
}
}
}
@@ -0,0 +1,142 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.HolderSet;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeGenerationSettings;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.GenerationStep;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.neoforged.neoforge.common.util.MemoizedFunction;
import org.jspecify.annotations.Nullable;
/** Flat layers with the authored biome distribution used by 1.21.1 Ages. */
public final class PortFlatChunkGenerator extends ChunkGenerator {
public static final MapCodec<PortFlatChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
BiomeSource.CODEC.fieldOf("biome_source").forGetter(generator -> generator.biomeSource),
FlatLevelGeneratorSettings.CODEC.fieldOf("settings").forGetter(PortFlatChunkGenerator::settings)
).apply(instance, PortFlatChunkGenerator::new));
private final FlatLevelGeneratorSettings settings;
private final MemoizedFunction<Holder<Biome>, BiomeGenerationSettings> generationSettingsGetter;
public PortFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings) {
super(biomeSource, this.generationSettingsGetter = MemoizedFunction.of(
biome -> generationSettings(settings, biome)));
this.settings = settings;
}
public FlatLevelGeneratorSettings settings() {
return settings;
}
private static BiomeGenerationSettings generationSettings(FlatLevelGeneratorSettings settings, Holder<Biome> biome) {
// Void biomes still control climate, but must not place terrain features.
if (settings.getLayersInfo().isEmpty()) return BiomeGenerationSettings.EMPTY;
if (biome.equals(settings.getBiome())) return settings.adjustGenerationSettings(biome);
BiomeGenerationSettings.PlainBuilder builder = new BiomeGenerationSettings.PlainBuilder();
List<HolderSet<PlacedFeature>> features = biome.value().getGenerationSettings().features();
for (int step = 0; step < features.size(); step++) {
if (step == GenerationStep.Decoration.UNDERGROUND_STRUCTURES.ordinal()
|| step == GenerationStep.Decoration.SURFACE_STRUCTURES.ordinal()) continue;
for (Holder<PlacedFeature> feature : features.get(step)) builder.addFeature(step, feature);
}
return builder.build();
}
@Override
protected MapCodec<? extends ChunkGenerator> codec() {
return CODEC;
}
@Override
public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> structures,
RandomState random, long seed) {
Stream<Holder<StructureSet>> entries = settings.structureOverrides()
.map(HolderSet::stream)
.orElseGet(() -> structures.listElements().map(holder -> (Holder<StructureSet>) holder));
return ChunkGeneratorStructureState.createForFlat(random, seed, getOrigin(random), biomeSource, entries);
}
@Override
public int getSpawnHeight(LevelHeightAccessor height) {
return height.getMinY() + Math.min(height.getHeight(), settings.getLayers().size());
}
@Override
public CompletableFuture<ChunkAccess> buildTerrain(ChunkAccess chunk, Blender blender,
RandomState random, StructureManager structures, BiomeManager biomes,
@Nullable WorldGenRegion carverBiomeRegion, Set<Holder<Biome>> possibleBiomes) {
List<BlockState> layers = settings.getLayers();
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
for (int layer = 0; layer < Math.min(chunk.getHeight(), layers.size()); layer++) {
BlockState state = layers.get(layer);
if (state == null) continue;
int y = chunk.getMinY() + layer;
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) {
chunk.setBlockState(pos.set(x, y, z), state);
floor.update(x, y, z, state);
surface.update(x, y, z, state);
}
}
return CompletableFuture.completedFuture(chunk);
}
@Override
public int getBaseHeight(int x, int z, Heightmap.Types type,
LevelHeightAccessor height, RandomState random) {
List<BlockState> layers = settings.getLayers();
for (int layer = Math.min(layers.size() - 1, height.getHeight() - 1); layer >= 0; layer--) {
BlockState state = layers.get(layer);
if (state != null && type.isOpaque().test(state)) return height.getMinY() + layer + 1;
}
return height.getMinY();
}
@Override
public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor height, RandomState random) {
return new NoiseColumn(height.getMinY(), settings.getLayers().stream().limit(height.getHeight())
.map(state -> state == null ? Blocks.AIR.defaultBlockState() : state)
.toArray(BlockState[]::new));
}
@Override public void addDebugScreenInfo(List<String> info, RandomState random,
BlockPos pos, SamplerContext context) { }
@Override public void spawnOriginalMobs(WorldGenRegion level) { }
@Override public int getMinY() { return 0; }
@Override public int getGenDepth() { return 384; }
@Override public int getSeaLevel() { return -63; }
@Override
public void refreshFeaturesPerStep() {
super.refreshFeaturesPerStep();
generationSettingsGetter.clear();
settings.updateLayers();
}
}
@@ -0,0 +1,83 @@
package net.dnorberg.rivencraft.age;
import net.minecraft.core.BlockPos;
import net.minecraft.core.QuartPos;
import net.minecraft.tags.BiomeTags;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
/** 1.21.1 floating-island shape adapted to the 26.3 chunk terrain stage. */
final class PortFloatingIslandFeature {
private static final long SALT = 0x464C4F4154494E47L;
private static final int SOURCE_RANGE = 3;
private static final int CHANCE = 128;
private PortFloatingIslandFeature() {}
static void generate(ChunkAccess chunk, long ageSeed, BlockState material) {
var target = chunk.getPos();
for (int sourceX = target.x() - SOURCE_RANGE; sourceX <= target.x() + SOURCE_RANGE; sourceX++) {
for (int sourceZ = target.z() - SOURCE_RANGE; sourceZ <= target.z() + SOURCE_RANGE; sourceZ++) {
RandomSource random = RandomSource.create(mix(ageSeed ^ SALT
^ (long) sourceX * 341873128712L ^ (long) sourceZ * 132897987541L));
if (random.nextInt(CHANCE) != 0) continue;
int centerX = sourceX * 16 + random.nextInt(16);
int centerZ = sourceZ * 16 + random.nextInt(16);
int centerY = Math.min(chunk.getMaxY() - 30, 168 + random.nextInt(48));
if (centerY < chunk.getMinY() + 24) continue;
int radiusX = 24 + random.nextInt(18);
int radiusZ = 24 + random.nextInt(18);
fill(chunk, ageSeed, material, centerX, centerY, centerZ, radiusX, radiusZ);
}
}
}
private static void fill(ChunkAccess chunk, long ageSeed, BlockState material,
int centerX, int centerY, int centerZ, int radiusX, int radiusZ) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int localX = 0; localX < 16; localX++) {
int worldX = originX + localX;
double dx = (worldX - centerX) / (double) radiusX;
for (int localZ = 0; localZ < 16; localZ++) {
int worldZ = originZ + localZ;
double dz = (worldZ - centerZ) / (double) radiusZ;
double distance = dx * dx + dz * dz;
if (distance >= 1.0) continue;
long detail = mix(ageSeed ^ (long) centerX * 341873128712L
^ (long) centerZ * 132897987541L ^ (long) worldX * 42317861L
^ (long) worldZ * 374761393L);
int top = centerY + (int) Math.round(3 * (1 - distance)) + Math.floorMod(detail, 3) - 1;
int depth = Math.max(1, (int) Math.round(3 + 17 * Math.pow(1 - distance, 1.5)));
// Biomes are already populated by createBiomes in 26.3, so
// sampling this chunk preserves its authored biome distribution.
var biome = chunk.getNoiseBiome(QuartPos.fromBlock(worldX), QuartPos.fromBlock(top),
QuartPos.fromBlock(worldZ));
BlockState soil = biome.is(BiomeTags.IS_END) || PortRegistryPages.isCaveBiome(biome)
? material : Blocks.DIRT.defaultBlockState();
BlockState cap = soil == material ? material : Blocks.GRASS_BLOCK.defaultBlockState();
for (int y = Math.max(top - depth, chunk.getMinY()); y <= top; y++) {
pos.set(worldX, y, worldZ);
if (!chunk.getBlockState(pos).isAir()) continue;
BlockState state = y == top ? cap : y >= top - 3 ? soil : material;
chunk.setBlockState(pos, state);
surface.update(localX, y, localZ, state);
floor.update(localX, y, localZ, state);
}
}
}
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,46 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** Ordered lake pages with fluid choices frozen when the Age is bound. */
public record PortLakePlan(List<Choice> choices) {
public static final Codec<PortLakePlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Choice.CODEC.listOf().fieldOf("choices").forGetter(PortLakePlan::choices)
).apply(instance, PortLakePlan::new));
public record Choice(Identifier page, Identifier fluid) {
public static final Codec<Choice> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("page").forGetter(Choice::page),
Identifier.CODEC.fieldOf("fluid").forGetter(Choice::fluid)
).apply(instance, Choice::new));
public Choice {
if (!isLake(page) || fluid == null) throw new IllegalArgumentException("Invalid lake choice");
}
}
public PortLakePlan {
choices = List.copyOf(choices);
if (choices.isEmpty() || choices.size() > 32)
throw new IllegalArgumentException("Lake plan must contain 1–32 choices");
}
public static boolean isLake(Identifier page) {
if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
return switch (page.getPath()) {
case "feature/surface_lakes", "feature/deep_lakes", "feature/lava_lakes" -> true;
default -> false;
};
}
public static boolean isDeep(Identifier page) { return page.getPath().equals("feature/deep_lakes"); }
public static Identifier placedFeature(Identifier page) {
if (!isLake(page)) throw new IllegalArgumentException("Unsupported lake page " + page);
return Identifier.withDefaultNamespace(isDeep(page) ? "lake_lava_underground" : "lake_lava_surface");
}
}
@@ -0,0 +1,52 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.Locale;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.Level;
/** Authored lighting choices using statically registered, client-synchronized types. */
public enum PortLighting {
NORMAL, DARK, BRIGHT;
public static final Codec<PortLighting> CODEC = Codec.STRING.xmap(
name -> valueOf(name.toUpperCase(Locale.ROOT)), mode -> mode.name().toLowerCase(Locale.ROOT));
public static PortLighting forPage(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
return switch (page.getPath()) {
case "lighting/normal" -> NORMAL;
case "lighting/dark" -> DARK;
case "lighting/bright" -> BRIGHT;
default -> null;
};
}
public static boolean isBright(Level level) {
return level.dimensionTypeRegistration().unwrapKey().map(key -> {
Identifier id = key.identifier();
return id.getNamespace().equals(Rivencraft.MOD_ID)
&& (id.getPath().equals("age_bright") || id.getPath().equals("age_cave_bright")
|| id.getPath().startsWith("age_bright_sky_")
|| id.getPath().startsWith("age_cave_bright_sky_"));
}).orElse(false);
}
Holder<DimensionType> dimensionType(MinecraftServer server, PortAgeTerrain terrain,
Holder<DimensionType> overworldType, PortSkyStyle sky) {
boolean cave = terrain == PortAgeTerrain.CAVE;
if (this == NORMAL && !cave && sky == PortSkyStyle.OVERWORLD) return overworldType;
String variant = this == BRIGHT ? (cave ? "age_cave_bright" : "age_bright")
: this == DARK || cave ? "age_dark" : "age_normal";
if (sky != PortSkyStyle.OVERWORLD) variant += "_sky_" + sky.name().toLowerCase(Locale.ROOT);
return server.registryAccess().lookupOrThrow(Registries.DIMENSION_TYPE).getOrThrow(
ResourceKey.create(Registries.DIMENSION_TYPE,
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, variant)));
}
}
@@ -0,0 +1,94 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
/** Disposable-world menu, ownership, codec and chest-persistence fixtures. */
final class PortNotebookProbe {
private static final BlockPos CHEST = new BlockPos(0, 64, 0);
private PortNotebookProbe() {}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
Container chest = (Container) level.getBlockEntity(CHEST);
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("1043b9f3-f348-4a73-b585-e0de13fd5c27"), "RivenNotebookProbe"));
int output = 0;
try {
for (boolean folder : List.of(false, true)) for (var hand : InteractionHand.values()) {
player.getInventory().clearContent();
player.getInventory().setSelectedSlot(0);
ItemStack collection = new ItemStack(folder ? Rivencraft.COLLATION_FOLDER.get() : Rivencraft.NOTEBOOK.get());
player.setItemInHand(hand, collection);
var menu = new NotebookMenu(1, player.getInventory(), hand);
int capacity = CollationFolderItem.capacity(collection);
if (!menu.stillValid(player) || menu.pageCapacity() != capacity
|| menu.getSlot(0).mayPlace(PageItem.createLinkPanelPage())
|| menu.getSlot(0).mayPlace(new ItemStack(net.minecraft.world.item.Items.DIAMOND)))
throw new IllegalStateException("Notebook capacity/page acceptance regression");
ItemStack page = PageItem.createSymbolPage(Identifier.fromNamespaceAndPath("rivencraft", "terrain/flat"));
player.getInventory().setItem(9, page.copyWithCount(3));
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS, 0, ContainerInput.QUICK_MOVE, player);
if (!player.getInventory().getItem(9).isEmpty() || contents(collection).pageCount() != 3)
throw new IllegalStateException("Notebook shift insertion regression");
for (int i = 0; i < 3; i++) menu.clicked(i, 0, ContainerInput.QUICK_MOVE, player);
if (contents(collection).pageCount() != 0) throw new IllegalStateException("Notebook shift extraction regression");
menu.getSlot(capacity - 1).set(page.copy());
if (contents(collection).pageCount() != 1 || !contents(collection).pages().get(0).isEmpty()
|| contents(collection).pages().get(capacity - 1).isEmpty())
throw new IllegalStateException("Notebook sparse position regression");
if (!menu.clickMenuButton(player, NotebookMenu.MOVE_PREVIOUS_BASE + capacity - 1)
|| !contents(collection).pages().get(capacity - 1).isEmpty())
throw new IllegalStateException("Notebook page movement regression");
if (folder && menu.getSlot(capacity).mayPlace(page))
throw new IllegalStateException("Folder exceeded 27 positions");
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS + 27, 0, ContainerInput.PICKUP, player);
if (player.getItemInHand(hand) != collection || !menu.getCarried().isEmpty())
throw new IllegalStateException("Notebook active item was movable");
var ops = RegistryOps.create(com.mojang.serialization.JsonOps.INSTANCE, level.registryAccess());
var encoded = NotebookContents.CODEC.encodeStart(ops, contents(collection)).getOrThrow();
var decoded = NotebookContents.CODEC.parse(ops, encoded).getOrThrow();
var buffer = new RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), level.registryAccess());
try {
NotebookContents.STREAM_CODEC.encode(buffer, decoded);
decoded = NotebookContents.STREAM_CODEC.decode(buffer);
if (decoded.pageCount() != 1 || decoded.pages().get(capacity - 2).isEmpty())
throw new IllegalStateException("Notebook save/network codec regression");
} finally { buffer.release(); }
chest.setItem(output++, collection.copy());
player.setItemInHand(hand, ItemStack.EMPTY);
}
} finally { player.getInventory().clearContent(); }
verify(level);
}
private static NotebookContents contents(ItemStack stack) {
return stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
for (int i = 0; i < 4; i++) {
var collection = chest.getItem(i);
int capacity = i < 2 ? 252 : 27;
if (CollationFolderItem.capacity(collection) != capacity || contents(collection).pageCount() != 1
|| contents(collection).pages().get(capacity - 2).isEmpty())
throw new IllegalStateException("Notebook chest persistence regression: " + i);
}
}
}
@@ -0,0 +1,91 @@
package net.dnorberg.rivencraft.age;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/** 26.3 port of Rivencraft 1.21.1's Legacy-style 4×4 plinth and 2×12×2 shaft. */
final class PortObelisksFeature {
private static final long SALT = 0x4F42454C49534B53L;
private static final int CHANCE = 128;
private static final int BASE = 4;
private static final int HEIGHT = 12;
private PortObelisksFeature() {}
record Site(int x, int z) {}
static Site site(long ageSeed, ChunkPos chunk, int occurrence) {
RandomSource random = RandomSource.create(mix(ageSeed ^ SALT
^ (long) chunk.x() * 341873128712L ^ (long) chunk.z() * 132897987541L
^ (long) occurrence * 0x9E3779B97F4A7C15L));
if (random.nextInt(CHANCE) != 0) return null;
return new Site(chunk.getMinBlockX() + 4 + random.nextInt(8),
chunk.getMinBlockZ() + 4 + random.nextInt(8));
}
static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, BlockState material, int occurrence) {
Site site = site(ageSeed, chunk.getPos(), occurrence);
if (site == null) return;
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
int minSurface = Integer.MAX_VALUE;
int maxSurface = Integer.MIN_VALUE;
int[][] surface = new int[BASE][BASE];
for (int dx = 0; dx < BASE; dx++) {
for (int dz = 0; dz < BASE; dz++) {
int x = site.x() + dx;
int z = site.z() + dz;
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z);
if (y <= level.getMinY() + 1) return;
BlockState ground = level.getBlockState(pos.set(x, y - 1, z));
if (!ground.isFaceSturdy(level, pos, Direction.UP)
|| !ground.getFluidState().isEmpty() || ground.hasBlockEntity()) return;
surface[dx][dz] = y;
minSurface = Math.min(minSurface, y);
maxSurface = Math.max(maxSurface, y);
}
}
if (maxSurface - minSurface > 2 || maxSurface + HEIGHT >= level.getMaxY()) return;
for (int dx = 0; dx < BASE; dx++) {
for (int dz = 0; dz < BASE; dz++) {
for (int y = surface[dx][dz]; y <= maxSurface; y++) {
if (!open(level.getBlockState(pos.set(site.x() + dx, y, site.z() + dz)))) return;
}
}
}
for (int dx = 1; dx <= 2; dx++) {
for (int dz = 1; dz <= 2; dz++) {
for (int y = maxSurface + 1; y <= maxSurface + HEIGHT; y++) {
if (!open(level.getBlockState(pos.set(site.x() + dx, y, site.z() + dz)))) return;
}
}
}
for (int dx = 0; dx < BASE; dx++) {
for (int dz = 0; dz < BASE; dz++) {
for (int y = surface[dx][dz]; y <= maxSurface; y++)
level.setBlock(pos.set(site.x() + dx, y, site.z() + dz), material, 2);
}
}
for (int dx = 1; dx <= 2; dx++) {
for (int dz = 1; dz <= 2; dz++) {
for (int y = maxSurface + 1; y <= maxSurface + HEIGHT; y++)
level.setBlock(pos.set(site.x() + dx, y, site.z() + dz), material, 2);
}
}
}
private static boolean open(BlockState state) {
return state.getFluidState().isEmpty() && (state.isAir() || state.canBeReplaced());
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,92 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Random;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
/** Seed-frozen terrain and biome choices for a book with no authored symbols. */
public final class PortRandomAgeSelection {
private PortRandomAgeSelection() {}
public record Selection(PortAgeTerrain terrain, PortBiomePlan biomes) {}
public static Selection select(MinecraftServer server, long seed) {
// Match the 1.21.1 Legacy-rank choices: Standard and Flat are common;
// the other terrain families are rarer. The sorted order is deliberate.
PortAgeTerrain terrain = terrain(seed);
if (terrain == PortAgeTerrain.VOID)
return new Selection(terrain, new PortBiomePlan(PortBiomePlan.Distribution.SINGLE,
List.of(Identifier.withDefaultNamespace("the_void"))));
PortBiomePlan.Distribution distribution = distribution(seed);
List<Identifier> eligible = eligibleBiomes(server, terrain);
if (eligible.size() < 2 && distribution != PortBiomePlan.Distribution.NATIVE)
distribution = PortBiomePlan.Distribution.SINGLE;
if (distribution == PortBiomePlan.Distribution.NATIVE)
return new Selection(terrain, new PortBiomePlan(distribution,
terrain == PortAgeTerrain.CAVE ? eligible : List.of()));
int count = distribution == PortBiomePlan.Distribution.SINGLE ? 1 : 2;
if (count > 1) {
Random countRandom = new Random(seed ^ 0x42494F4D45434F55L);
while (count < eligible.size() && countRandom.nextInt(3) != 0) count++;
}
List<Identifier> shuffled = new ArrayList<>(eligible);
Collections.shuffle(shuffled, new Random(seed ^ 0x42494F4D45504F4FL));
return new Selection(terrain, new PortBiomePlan(distribution, shuffled.subList(0, count)));
}
public static List<PortAgeTerrain> terrains() {
return List.of(PortAgeTerrain.AMPLIFIED, PortAgeTerrain.CAVE, PortAgeTerrain.FLAT,
PortAgeTerrain.ISLAND, PortAgeTerrain.STANDARD, PortAgeTerrain.VOID);
}
public static PortAgeTerrain terrain(long seed) {
return weighted(new Random(seed ^ 0x5445525241494E4CL), terrains(), new int[]{1, 1, 5, 1, 6, 1});
}
/** First AgeTraits draw in 1.21.1; shared by random and partly authored Ages. */
public static PortBiomePlan.Distribution distribution(long seed) {
return weighted(new Random(seed ^ 0x524956454E435241L),
List.of(PortBiomePlan.Distribution.NATIVE, PortBiomePlan.Distribution.SINGLE,
PortBiomePlan.Distribution.MEDIUM, PortBiomePlan.Distribution.TILED,
PortBiomePlan.Distribution.GRID, PortBiomePlan.Distribution.TINY,
PortBiomePlan.Distribution.SMALL, PortBiomePlan.Distribution.LARGE,
PortBiomePlan.Distribution.HUGE),
new int[]{3, 3, 3, 1, 1, 1, 1, 1, 1});
}
public static List<Identifier> eligibleBiomes(MinecraftServer server, PortAgeTerrain terrain) {
if (terrain == PortAgeTerrain.VOID) return List.of(Identifier.withDefaultNamespace("the_void"));
if (terrain == PortAgeTerrain.CAVE) {
return caveBiomes(server.registryAccess());
}
List<Identifier> surface = server.overworld().getChunkSource().getGenerator().getBiomeSource()
.possibleBiomes().stream().flatMap(holder -> holder.unwrapKey().stream())
.map(key -> key.identifier()).distinct().sorted(Comparator.naturalOrder()).toList();
return surface.isEmpty() ? List.of(Identifier.withDefaultNamespace("plains")) : surface;
}
public static List<Identifier> caveBiomes(net.minecraft.core.HolderLookup.Provider registries) {
var caves = registries.lookupOrThrow(Registries.BIOME).listElements()
.filter(PortRegistryPages::isCaveBiome).map(holder -> holder.key().identifier()).sorted().toList();
return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves;
}
static <T> T weighted(Random random, List<T> choices, int[] weights) {
int total = 0;
for (int weight : weights) total += weight;
int ticket = random.nextInt(total);
for (int index = 0; index < choices.size(); index++) {
ticket -= weights[index];
if (ticket < 0) return choices.get(index);
}
throw new IllegalStateException("Random Age ticket was not consumed");
}
}
@@ -0,0 +1,85 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
import net.minecraft.resources.Identifier;
/** Frozen page-free material features and rare formations from 1.21.1. */
public final class PortRandomFeatureSelection {
private PortRandomFeatureSelection() {}
public record Selection(PortRockPlan rocks, PortLakePlan lakes, PortTerrainFeaturePlan terrain, int dungeons) {}
public static Selection select(net.minecraft.server.MinecraftServer server, long seed,
PortAgeTerrain terrain, PortBiomePlan biomes) {
var base = select(seed, terrain);
var structures = PortRandomStructureSelection.select(server, seed, terrain, biomes);
var plan = base.terrain();
plan = plan == null ? new PortTerrainFeaturePlan(List.of(), false, 0, false, structures.structures(), List.of(), true)
: new PortTerrainFeaturePlan(plan.choices(), plan.skylands(), plan.denseOres(), plan.starFissures(),
structures.structures(), plan.carvers(), true);
return new Selection(base.rocks(), base.lakes(), plan, structures.dungeons());
}
public static Selection select(long seed, PortAgeTerrain terrain) {
var random = new Random(seed ^ 0x4D4154455249414CL);
int count = random.nextInt(3);
var terrainPage = id("terrain/" + terrain.name().toLowerCase(java.util.Locale.ROOT));
var candidates = new ArrayList<PortRankedChoice.Option<Identifier>>();
for (String name : List.of("rocks", "floating_islands", "tendrils", "spheres", "spikes", "obelisks",
"surface_lakes", "deep_lakes", "lava_lakes")) {
var page = id("feature/" + name);
if (PortAuthoredWriting.supports(List.of(terrainPage, page)))
candidates.add(new PortRankedChoice.Option<>(page, rank(name)));
}
var pages = new ArrayList<Identifier>();
pages.add(terrainPage);
List<Identifier> solids = PortRegistryPages.solidMaterialIds(), fluids = PortRegistryPages.fluidMaterialIds();
for (int selected = 0; selected < count && !candidates.isEmpty();) {
Identifier feature = PortRankedChoice.choose(random, candidates);
candidates.removeIf(option -> option.value().equals(feature));
var materials = PortLakePlan.isLake(feature) ? fluids : solids;
if (materials.isEmpty()) continue;
pages.add(PortRegistryPages.blockPageId(materials.get(random.nextInt(materials.size()))));
pages.add(feature);
selected++;
}
if (terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID
&& Math.floorMod(mix(seed ^ 0x534B594C414E4453L), 8) == 0)
pages.add(PortTerrainFeaturePlan.SKYLANDS);
if (terrain != PortAgeTerrain.CAVE && terrain != PortAgeTerrain.VOID && random.nextInt(10) == 0)
pages.add(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS);
if (randomStarFissure(seed)) pages.add(PortTerrainFeaturePlan.STAR_FISSURES);
return new Selection(PortAuthoredWriting.rockPlan(pages, seed), PortAuthoredWriting.lakePlan(pages, seed),
PortAuthoredWriting.terrainFeaturePlan(pages, seed), 0);
}
private static int rank(String name) {
return switch (name) {
case "surface_lakes", "deep_lakes" -> 1;
case "lava_lakes" -> 2;
case "spheres", "spikes", "obelisks" -> 3;
default -> 4;
};
}
private static boolean randomStarFissure(long seed) {
var random = new Random(mix(seed ^ 0x535441524752414DL));
for (int step = 0; step < 27; step++) {
int branch = random.nextInt(5);
if (branch == 0) return false;
if (random.nextInt(9) == 0) return true;
if (branch == 1) return false;
}
return false;
}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,69 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.Set;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.dimension.LevelStem;
/** Creation-only structure completion, adapted from 1.21.1 AgeStructureSelection. */
public final class PortRandomStructureSelection {
private static final Identifier DUNGEONS = page("dungeons");
private PortRandomStructureSelection() {}
public record Selection(int dungeons, List<Identifier> structures) {
public Selection { structures = List.copyOf(structures); }
}
public static Selection select(MinecraftServer server, long seed, PortAgeTerrain terrain, PortBiomePlan plan) {
Set<Identifier> biomes = actualBiomes(server, terrain, plan);
var eligible = new ArrayList<PortRankedChoice.Option<Identifier>>();
for (String name : List.of("dungeons", "mineshafts", "nether_fortresses", "strongholds", "villages",
"trial_chambers", "bastions", "buried_treasure", "shipwrecks", "ocean_ruins", "pillager_outposts",
"ruined_portals", "nether_fossils", "ancient_cities", "trail_ruins", "abandoned_camps")) {
Identifier feature = page(name);
boolean allowed = feature.equals(DUNGEONS)
? net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(List.of(
Identifier.fromNamespaceAndPath("rivencraft", "terrain/" + terrain.name().toLowerCase(java.util.Locale.ROOT)), feature))
&& server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
.get(ResourceKey.create(Registries.PLACED_FEATURE,
Identifier.fromNamespaceAndPath("rivencraft", "dungeons"))).isPresent()
: PortStructurePages.randomEligible(feature, terrain, biomes, server.registryAccess());
if (allowed) eligible.add(new PortRankedChoice.Option<>(feature, rank(name)));
}
var random = new Random(seed ^ 0x5354525543545552L);
int count = random.nextInt(3), dungeons = 0;
var structures = new ArrayList<Identifier>();
for (int selected = 0; selected < count && !eligible.isEmpty(); selected++) {
var feature = PortRankedChoice.choose(random, eligible);
if (feature.equals(DUNGEONS)) dungeons++;
else structures.add(feature);
eligible.removeIf(option -> option.value().equals(feature));
}
return new Selection(dungeons, structures);
}
private static Set<Identifier> actualBiomes(MinecraftServer server, PortAgeTerrain terrain, PortBiomePlan plan) {
if (plan != null && !plan.biomes().isEmpty())
return Set.copyOf(plan.biomes());
if (terrain == PortAgeTerrain.FLAT) return Set.of(Identifier.withDefaultNamespace("plains"));
if (terrain == PortAgeTerrain.VOID) return Set.of(Identifier.withDefaultNamespace("the_void"));
if (terrain == PortAgeTerrain.CAVE) return Set.of(Identifier.withDefaultNamespace("lush_caves"));
return server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM).getOrThrow(LevelStem.OVERWORLD)
.value().generator().getBiomeSource().possibleBiomes().stream()
.flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier())
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
private static int rank(String name) {
return switch (name) {
case "villages", "strongholds", "mineshafts" -> 1;
case "dungeons", "nether_fortresses" -> 2;
default -> 3;
};
}
private static Identifier page(String name) { return Identifier.fromNamespaceAndPath("rivencraft", "feature/" + name); }
}
@@ -0,0 +1,36 @@
package net.dnorberg.rivencraft.age;
import java.util.HashMap;
import java.util.List;
import java.util.Random;
/** The 1.21.1 LegacyRankChoice rule, retained for seeded grammar completion. */
final class PortRankedChoice {
private PortRankedChoice() {}
record Option<T>(T value, int rank) {}
static <T> T choose(Random random, List<Option<T>> options) {
if (options.isEmpty()) throw new IllegalArgumentException("No ranked choices");
var counts = new HashMap<Integer, Integer>();
int highest = 0;
for (var option : options) {
counts.merge(option.rank(), 1, Integer::sum);
highest = Math.max(highest, option.rank());
}
var weights = new HashMap<Integer, Integer>();
int weight = 1, previousTotal = 0;
for (int rank = highest; rank >= 0; rank--) {
int count = counts.getOrDefault(rank, 0);
if (weight != 1 && count > 0) weight = Math.max(weight, previousTotal / count + 1);
weights.put(rank, weight);
previousTotal = count * weight;
weight++;
}
int ticket = random.nextInt(options.stream().mapToInt(option -> weights.get(option.rank())).sum());
for (var option : options) {
ticket -= weights.get(option.rank());
if (ticket < 0) return option.value();
}
throw new IllegalStateException("Ranked choice ticket was not consumed");
}
}
@@ -0,0 +1,44 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.item.ItemStack;
/** Called only after successful Descriptive Book travel and its Disarm effect. */
public final class PortReturnLinks {
private PortReturnLinks() {}
public static void afterSuccessfulTravel(ServerPlayer player, Identifier age, LinkBookTarget departure) {
var mode = PortConfig.RETURN_LINK_MODE.get();
if (mode == PortConfig.ReturnLinkMode.DISABLED) return;
var storage = player.level().getServer().overworld().getDataStorage();
boolean issue = shouldIssue(mode, storage.computeIfAbsent(PortAgeVisits.TYPE), age);
if (!issue) return;
ItemStack book = createBook(departure);
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
// Persist the once-per-Age marker before the next arrival can issue another.
if (mode == PortConfig.ReturnLinkMode.ON_CREATION)
storage.saveAndJoin();
}
static boolean shouldIssue(PortConfig.ReturnLinkMode mode, PortAgeVisits visits, Identifier age) {
return switch (mode) {
case DISABLED -> false;
case ALWAYS -> true;
case ON_CREATION -> visits.markFirstVisit(age);
};
}
static ItemStack createBook(LinkBookTarget departure) {
ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get());
book.set(Rivencraft.LINK_BOOK_TARGET.get(), departure);
book.set(DataComponents.CUSTOM_NAME, Component.translatable("item.rivencraft.linking_book.return"));
return book;
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Optional;
import net.minecraft.resources.Identifier;
/** Frozen, ordered Rocks materials. Old single-material beta Ages remain readable. */
public record PortRockPlan(List<Identifier> materials) {
public static final Codec<PortRockPlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.optionalFieldOf("material").forGetter(plan -> Optional.empty()),
Identifier.CODEC.listOf().optionalFieldOf("materials", List.of()).forGetter(PortRockPlan::materials)
).apply(instance, (old, current) -> new PortRockPlan(current.isEmpty()
? old.map(List::of).orElse(List.of()) : current)));
public PortRockPlan {
materials = List.copyOf(materials);
if (materials.isEmpty() || materials.size() > 32)
throw new IllegalArgumentException("Rocks plan must contain 1–32 materials");
}
public PortRockPlan(Identifier material) { this(List.of(material)); }
}
@@ -0,0 +1,91 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.stream.Stream;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
/** 26.3 resolver port of the seeded 1.21.1 Mystcraft-style region distribution. */
public final class PortScaledBiomeSource extends BiomeSource implements BiomeResolver {
public static final MapCodec<PortScaledBiomeSource> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
Biome.LIST_CODEC.fieldOf("biomes").forGetter(source -> source.biomes),
Codec.LONG.fieldOf("seed").forGetter(source -> source.seed),
Codec.intRange(0, 4).fieldOf("scale").forGetter(source -> source.scale)
).apply(instance, PortScaledBiomeSource::new));
private final HolderSet<Biome> biomes;
private final long seed;
private final int scale;
private final int cellWidth;
public PortScaledBiomeSource(HolderSet<Biome> biomes, long seed, int scale) {
if (biomes.size() < 2 || scale < 0 || scale > 4)
throw new IllegalArgumentException("Scaled regions need two biomes and scale 0..4");
this.biomes = biomes;
this.seed = seed;
this.scale = scale;
// Quart coordinates; Tiny starts at 16 blocks and each scale doubles.
this.cellWidth = 4 << scale;
}
@Override
protected MapCodec<? extends BiomeSource> codec() {
return CODEC;
}
@Override
protected Stream<Holder<Biome>> collectPossibleBiomes() {
return biomes.stream();
}
@Override
public BiomeResolver createResolver(Climate.Sampler sampler) {
return this;
}
@Override
public Holder<Biome> getNoiseBiome(int x, int y, int z) {
int baseX = Math.floorDiv(x, cellWidth);
int baseZ = Math.floorDiv(z, cellWidth);
int closestX = baseX;
int closestZ = baseZ;
double closestDistance = Double.POSITIVE_INFINITY;
for (int cellX = baseX - 1; cellX <= baseX + 1; cellX++) {
for (int cellZ = baseZ - 1; cellZ <= baseZ + 1; cellZ++) {
long cellSeed = mix(seed, cellX, cellZ);
double centerX = (cellX + 0.5D + jitter(cellSeed) * 0.8D) * cellWidth;
double centerZ = (cellZ + 0.5D + jitter(mix64(cellSeed)) * 0.8D) * cellWidth;
double deltaX = x - centerX;
double deltaZ = z - centerZ;
double distance = deltaX * deltaX + deltaZ * deltaZ;
if (distance < closestDistance) {
closestDistance = distance;
closestX = cellX;
closestZ = cellZ;
}
}
}
return biomes.get(Math.floorMod((int) mix(seed ^ 0x42494F4D45534CL,
closestX, closestZ), biomes.size()));
}
private static double jitter(long value) {
return ((value >>> 11) * 0x1.0p-53) - 0.5D;
}
private static long mix(long seed, int x, int z) {
return mix64(seed ^ (long) x * 0x9E3779B97F4A7C15L ^ (long) z * 0xC2B2AE3D27D4EB4FL);
}
private static long mix64(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,70 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.tags.BlockTags;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.ShelfMushroomBlock;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.feature.treedecorators.ShelfMushroomDecorator;
import net.minecraft.world.level.levelgen.feature.treedecorators.TreeDecorator;
/** Shelf mushrooms on exposed log faces, regardless of how the logs were generated. */
final class PortShelfMushrooms {
private PortShelfMushrooms() {}
static List<BlockPos> findHosts(ChunkAccess chunk) {
List<BlockPos> logs = new ArrayList<>();
var sections = chunk.getSections();
for (int index = 0; index < sections.length; index++) {
var section = sections[index];
if (!section.maybeHas(state -> state.is(BlockTags.LOGS))) continue;
int baseY = chunk.getSectionYFromSectionIndex(index) << 4;
for (int y = 0; y < 16; y++) for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) {
if (section.getBlockState(x, y, z).is(BlockTags.LOGS))
logs.add(new BlockPos(chunk.getPos().getMinBlockX() + x, baseY + y,
chunk.getPos().getMinBlockZ() + z));
}
}
return logs;
}
static void decorate(WorldGenLevel level, ChunkAccess chunk, List<BlockPos> logs, RandomSource random) {
// Reuse vanilla replaceability/water checks, but not its tree-base height
// band: a log tendril or sphere has no meaningful tree base.
TreeDecorator.Context context = new TreeDecorator.Context(level,
(pos, state) -> level.setBlock(pos, state, 2), random, Set.of(), Set.of(), Set.of());
for (BlockPos log : logs) {
if (!level.getBlockState(log).is(BlockTags.LOGS)) continue;
Direction first = Direction.Plane.HORIZONTAL.getRandomDirection(random);
for (Direction facing : List.of(first, first.getClockWise())) {
if (random.nextFloat() > 0.25F) continue;
BlockPos pos = log.relative(facing);
if ((pos.getX() >> 4) != chunk.getPos().x() || (pos.getZ() >> 4) != chunk.getPos().z()) continue;
if (pos.getY() < level.getMinY() || pos.getY() >= level.getMaxY()) continue;
if (!level.getFluidState(pos).isEmpty()
|| !ShelfMushroomDecorator.isBlockReplaceableWithShelfMushroom(context, pos)
|| level.getBlockState(pos.below()).is(Blocks.SHELF_MUSHROOM)) continue;
boolean adjacent = false;
for (Direction side : Direction.Plane.HORIZONTAL) {
if (level.getBlockState(pos.relative(side)).is(Blocks.SHELF_MUSHROOM)) {
adjacent = true;
break;
}
}
if (adjacent) continue;
var mushroom = Blocks.SHELF_MUSHROOM.defaultBlockState()
.setValue(ShelfMushroomBlock.FACING, facing)
.setValue(ShelfMushroomBlock.AGE, random.nextInt(2));
if (!mushroom.canSurvive(level, pos)) continue;
context.setBlock(pos, mushroom);
break;
}
}
}
}
@@ -0,0 +1,66 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** Persisted sky styles, including the client-rendered Legacy static background. */
public enum PortSkyStyle {
OVERWORLD, NETHER, END, STATIC;
public static final Codec<PortSkyStyle> CODEC = Codec.STRING.xmap(
name -> valueOf(name.toUpperCase(Locale.ROOT)), sky -> sky.name().toLowerCase(Locale.ROOT));
public static PortSkyStyle forPage(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
return switch (page.getPath()) {
case "sky/overworld" -> OVERWORLD;
case "sky/nether" -> NETHER;
case "sky/end" -> END;
case "sky/static" -> STATIC;
default -> null;
};
}
public static PortSkyStyle resolve(List<Identifier> pages, long seed) {
return pages.stream().map(PortSkyStyle::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> pages.stream().anyMatch(PortSkyStyle::isCelestial)
? OVERWORLD : random(seed));
}
/** Nether-style Ages use their fog clear color as the visible backdrop. */
public static float fogShade(net.minecraft.world.level.Level level) {
boolean nether = level.dimensionTypeRegistration().unwrapKey().map(key ->
key.identifier().getNamespace().equals("rivencraft")
&& key.identifier().getPath().endsWith("_sky_nether")).orElse(false);
var cycle = PortDayCycle.forLevel(level);
return nether && !cycle.bodies().isEmpty()
? 1F - 0.65F * PortCelestialBody.eclipseAt(cycle.bodies(), PortDayCycle.nativeTicks(level)) : 1F;
}
private static boolean isCelestial(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
return switch (page.getPath()) {
// Preserve the established compatible-default rule for written controllers.
case "celestial/normal_time", "celestial/sun", "celestial/moon" -> true;
default -> false;
};
}
private static PortSkyStyle random(long seed) {
// Match the first draw in 1.21.1 AgeSkyProfile.random: its equally
// likely INHERITED and OVERWORLD slots currently share the native
// Overworld presentation here. Do not add unported colors/bodies.
int choice = new Random(seed ^ 0x534B5950524F464CL).nextInt(5);
return switch (choice) {
case 0, 1 -> OVERWORLD;
case 2 -> NETHER;
case 3 -> END;
case 4 -> STATIC;
default -> throw new IllegalStateException("Invalid random sky choice");
};
}
}
@@ -0,0 +1,87 @@
package net.dnorberg.rivencraft.age;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/**
* 26.3 port of Rivencraft's subtractive Skylands filter, adapted from
* Mystcraft Legacy's SymbolSkylands (LGPL-3.0-or-later). Keeps upper solid
* terrain above a seven-octave cutoff near Y=76 and removes liquids.
*/
final class PortSkylandsFeature {
private static final long SALT = 0x534B594C414E4453L;
private static final int BASE_CUTOFF = 76;
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
private PortSkylandsFeature() {}
static void filter(ChunkAccess chunk, long ageSeed) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
int minY = chunk.getMinY();
int maxY = chunk.getMaxY();
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int localX = 0; localX < 16; localX++) {
for (int localZ = 0; localZ < 16; localZ++) {
int cutoff = cutoff(ageSeed, originX + localX, originZ + localZ);
for (int y = minY; y < maxY; y++) {
pos.set(originX + localX, y, originZ + localZ);
BlockState state = chunk.getBlockState(pos);
if (!removes(state, y, cutoff)) continue;
chunk.setBlockState(pos, AIR);
surface.update(localX, y, localZ, AIR);
floor.update(localX, y, localZ, AIR);
}
}
}
}
static boolean removes(BlockState state, int y, int cutoff) {
return !state.isAir() && (y <= cutoff || state.liquid());
}
static int cutoff(long ageSeed, int x, int z) {
double variation = 0.0D;
int cellSize = 128;
double amplitude = 12.0D;
for (int octave = 0; octave < 7; octave++) {
variation += valueNoise(ageSeed ^ SALT ^ (long) octave * 0x9E3779B97F4A7C15L,
x, z, cellSize) * amplitude;
cellSize /= 2;
amplitude /= 2.0D;
}
return BASE_CUTOFF + (int) Math.round(variation);
}
private static double valueNoise(long seed, int x, int z, int cellSize) {
int cellX = Math.floorDiv(x, cellSize);
int cellZ = Math.floorDiv(z, cellSize);
double u = smooth((x - cellX * cellSize) / (double) cellSize);
double v = smooth((z - cellZ * cellSize) / (double) cellSize);
double north = lerp(value(seed, cellX, cellZ), value(seed, cellX + 1, cellZ), u);
double south = lerp(value(seed, cellX, cellZ + 1), value(seed, cellX + 1, cellZ + 1), u);
return lerp(north, south, v);
}
private static double value(long seed, int x, int z) {
long hash = mix(seed ^ (long) x * 0xD1B54A32D192ED03L ^ (long) z * 0xABC98388FB8FAC03L);
return (hash >>> 11) * 0x1.0p-52 - 1.0D;
}
private static double smooth(double value) { return value * value * (3.0D - 2.0D * value); }
private static double lerp(double first, double second, double amount) {
return first + (second - first) * amount;
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,122 @@
package net.dnorberg.rivencraft.age;
import java.util.Random;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/**
* 26.3 adaptation of Rivencraft 1.21.1's solid sphere nodes, derived from
* Mystcraft Legacy's MapGenSpheresMyst (Mystcraft contributors,
* LGPL-3.0-or-later, commit 9bc8ddc). Writes stay within the target chunk.
*/
final class PortSpheresFeature {
private static final int SOURCE_RANGE = 8;
private static final long SALT = 0x53504845524553L;
private PortSpheresFeature() {}
static void generate(ChunkAccess chunk, long ageSeed, BlockState material) {
var target = chunk.getPos();
Random master = new Random(ageSeed ^ SALT);
long xSeed = master.nextLong();
long zSeed = master.nextLong();
for (int sx = target.x() - SOURCE_RANGE; sx <= target.x() + SOURCE_RANGE; sx++) {
for (int sz = target.z() - SOURCE_RANGE; sz <= target.z() + SOURCE_RANGE; sz++) {
Random source = new Random((long) sx * xSeed ^ (long) sz * zSeed ^ ageSeed ^ SALT);
if (source.nextFloat() > 0.05F) continue;
double x = sx * 16 + source.nextInt(16);
double y = source.nextInt(source.nextInt(192) + 1) + 32;
double z = sz * 16 + source.nextInt(16);
trace(chunk, material, source.nextLong(), x, y, z,
1.0F + source.nextFloat() * 4.0F, 0, 0, -1, -1);
}
}
}
private static void trace(ChunkAccess chunk, BlockState material, long seed,
double x, double y, double z, float radius, float yaw, float pitch,
int step, int length) {
Random random = new Random(seed);
if (length <= 0) {
int maximum = SOURCE_RANGE * 16 - 16;
length = maximum - random.nextInt(maximum / 4);
}
boolean singleNode = step == -1;
if (singleNode) step = length / 2;
int splitAt = random.nextInt(length / 2) + length / 4;
boolean slowPitch = random.nextInt(6) == 0;
float yawDrift = 0;
float pitchDrift = 0;
double middleX = chunk.getPos().getMinBlockX() + 8;
double middleZ = chunk.getPos().getMinBlockZ() + 8;
for (; step < length; step++) {
double nodeRadius = 1.5D + Math.sin(step * Math.PI / length) * radius;
float horizontal = Mth.cos(pitch);
x += Mth.cos(yaw) * horizontal;
y += Mth.sin(pitch);
z += Mth.sin(yaw) * horizontal;
pitch *= slowPitch ? 0.92F : 0.7F;
pitch += pitchDrift * 0.1F;
yaw += yawDrift * 0.1F;
pitchDrift = pitchDrift * 0.9F
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 2.0F;
yawDrift = yawDrift * 0.75F
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 4.0F;
if (!singleNode && step == splitAt && radius > 1.0F) {
trace(chunk, material, random.nextLong(), x, y, z,
random.nextFloat() * 0.5F + 0.5F, yaw - Mth.HALF_PI, pitch / 3, step, length);
trace(chunk, material, random.nextLong(), x, y, z,
random.nextFloat() * 0.5F + 0.5F, yaw + Mth.HALF_PI, pitch / 3, step, length);
return;
}
if (!singleNode && random.nextInt(4) == 0) continue;
double dx = x - middleX;
double dz = z - middleZ;
double remaining = length - step;
double reach = radius + 18;
if (dx * dx + dz * dz - remaining * remaining > reach * reach) return;
if (Math.abs(dx) <= 16 + nodeRadius * 2 && Math.abs(dz) <= 16 + nodeRadius * 2)
fillNode(chunk, material, x, y, z, nodeRadius);
if (singleNode) break;
}
}
private static void fillNode(ChunkAccess chunk, BlockState material,
double centerX, double centerY, double centerZ, double radius) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
int minX = Math.max(0, Mth.floor(centerX - radius) - originX - 1);
int maxX = Math.min(16, Mth.floor(centerX + radius) - originX + 2);
int minZ = Math.max(0, Mth.floor(centerZ - radius) - originZ - 1);
int maxZ = Math.min(16, Mth.floor(centerZ + radius) - originZ + 2);
int minY = Math.max(chunk.getMinY() + 1, Mth.floor(centerY - radius) - 1);
int maxY = Math.min(chunk.getMaxY(), Mth.floor(centerY + radius) + 2);
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int worldY = minY; worldY < maxY; worldY++) {
double dy = (worldY + 0.5D - centerY) / radius;
for (int localZ = minZ; localZ < maxZ; localZ++) {
double dz = (originZ + localZ + 0.5D - centerZ) / radius;
for (int localX = minX; localX < maxX; localX++) {
double dx = (originX + localX + 0.5D - centerX) / radius;
if (!insideSphere(dx, dy, dz)) continue;
pos.set(originX + localX, worldY, originZ + localZ);
BlockState previous = chunk.getBlockState(pos);
if (previous == material || previous.is(Blocks.BEDROCK)) continue;
chunk.setBlockState(pos, material);
surface.update(localX, worldY, localZ, material);
floor.update(localX, worldY, localZ, material);
}
}
}
}
static boolean insideSphere(double dx, double dy, double dz) {
return dx * dx + dy * dy + dz * dz < 1.0D;
}
}
@@ -0,0 +1,56 @@
package net.dnorberg.rivencraft.age;
import net.minecraft.core.BlockPos;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/**
* 26.3 port of Rivencraft 1.21.1's rare surface spikes, adapted from
* Mystcraft Legacy's WorldGenSpikesAdv (Mystcraft contributors,
* LGPL-3.0-or-later, commit 9bc8ddc). The footprint stays within one chunk.
*/
final class PortSpikesFeature {
private static final long SALT = 0x5350494B4553L;
private PortSpikesFeature() {}
static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, BlockState material, int occurrence) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
RandomSource random = RandomSource.create(ageSeed ^ SALT
^ (long) originX * 341873128712L ^ (long) originZ * 132897987541L
^ (long) occurrence * 0x9E3779B97F4A7C15L);
if (random.nextInt(18) != 0) return;
int x = originX + 4 + random.nextInt(8);
int z = originZ + 4 + random.nextInt(8);
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z);
int height = 6 + random.nextInt(32);
int width = 1 + random.nextInt(4);
if (y < level.getMinY() + 1 || y + height >= level.getMaxY()) return;
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
if (!level.isEmptyBlock(pos.set(x, y, z))) return;
for (int px = x - width; px <= x + width; px++) {
for (int pz = z - width; pz <= z + width; pz++) {
if (!insideFootprint(px - x, pz - z, width)) continue;
if (!level.getBlockState(pos.set(px, y - 1, pz)).isSolid()) return;
}
}
for (int px = x - width; px <= x + width; px++) {
for (int pz = z - width; pz <= z + width; pz++) {
if (!insideFootprint(px - x, pz - z, width)) continue;
int top = y + 1 + random.nextInt(random.nextInt(height) + 1);
for (int py = y; py < top; py++) {
pos.set(px, py, pz);
if (level.isEmptyBlock(pos)) level.setBlock(pos, material, 2);
}
}
}
}
static boolean insideFootprint(int dx, int dz, int width) {
return dx * dx + dz * dz <= width * width + 1;
}
}
@@ -0,0 +1,107 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/**
* Ports Rivencraft 1.21.1's Legacy fissure footprint/carving.
* Derived from Mystcraft's WorldGenMystStarFissure, LGPL-3.0,
* copyright XCompWiz and Mystcraft contributors.
*/
public final class PortStarFissureFeature {
private static final long SALT = 0x5354415246495353L;
private PortStarFissureFeature() {}
/** Per-Age data prevents revisits/restarts from carving the same fissure again. */
private static final class Arrival extends SavedData {
private static final Codec<Arrival> CODEC = BlockPos.CODEC.optionalFieldOf("spawn").codec()
.xmap(Arrival::new, data -> Optional.ofNullable(data.spawn));
private static final SavedDataType<Arrival> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "star_fissure_arrival"),
() -> new Arrival(Optional.empty()), CODEC);
private BlockPos spawn;
private Arrival(Optional<BlockPos> spawn) { this.spawn = spawn.orElse(null); }
}
public static BlockPos savedSpawn(ServerLevel level) {
return level.getDataStorage().computeIfAbsent(Arrival.TYPE).spawn;
}
public static void generateOnce(ServerLevel level, BlockPos spawn, boolean caveTerrain) {
Arrival data = level.getDataStorage().computeIfAbsent(Arrival.TYPE);
if (data.spawn != null) return;
generate(level, level.getSeed(), spawn, caveTerrain);
data.spawn = spawn.immutable();
data.setDirty();
}
/** Legacy chooses 8–23 blocks after the spawn chunk, then grows along +Z. */
public static List<BlockPos> footprint(long ageSeed, BlockPos spawn) {
long chunkX = spawn.getX() >> 4;
long chunkZ = spawn.getZ() >> 4;
Random random = new Random(ageSeed ^ SALT ^ chunkX ^ (chunkZ << 32));
int originX = ((int) chunkX << 4) + random.nextInt(16) + 8;
int originZ = ((int) chunkZ << 4) + random.nextInt(16) + 8;
int length = random.nextInt(8) + 10;
int[][] intervals = new int[length][2];
List<BlockPos> columns = new ArrayList<>();
addRow(columns, originX, originZ, 0, 0, 0);
for (int row = 1; row < length; row++) {
int range = length + Math.min(row, length - row) + 1;
int right = (random.nextInt(range) - (range >> 1) + intervals[row - 1][1]) / 2;
int left = (random.nextInt(range) - (range >> 1) + intervals[row - 1][0]) / 2;
if (left > right) {
int swap = left;
left = right;
right = swap;
}
left = Math.min(left, intervals[row - 1][1]);
right = Math.max(right, intervals[row - 1][0]);
intervals[row][0] = left;
intervals[row][1] = right;
addRow(columns, originX, originZ, row, left, right);
}
return List.copyOf(columns);
}
private static void addRow(List<BlockPos> columns, int originX, int originZ, int row, int left, int right) {
for (int x = left; x <= right; x++) {
// Only X/Z are used; the real floor is the dimension's minimum Y.
columns.add(new BlockPos(originX + x, 0, originZ + row * 2));
columns.add(new BlockPos(originX + x, 0, originZ + row * 2 + 1));
}
}
private static void generate(ServerLevel level, long ageSeed, BlockPos spawn, boolean caveTerrain) {
int floorY = level.getMinY();
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
for (BlockPos column : footprint(ageSeed, spawn)) {
int top = level.getHeight(Heightmap.Types.WORLD_SURFACE, column.getX(), column.getZ());
if (caveTerrain) {
for (int y = Math.max(spawn.getY(), floorY + 1); y < top; y++) {
if (level.getBlockState(mutable.set(column.getX(), y, column.getZ())).is(Blocks.BEDROCK)) {
top = y;
break;
}
}
}
for (int y = floorY + 1; y < top; y++)
level.setBlock(mutable.set(column.getX(), y, column.getZ()), Blocks.AIR.defaultBlockState(), 2);
level.setBlock(mutable.set(column.getX(), floorY, column.getZ()),
Rivencraft.STAR_FISSURE.get().defaultBlockState(), 2);
}
}
}
@@ -0,0 +1,205 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.MapCodec;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import net.dnorberg.rivencraft.mixin.StructurePlacementAccessor;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.HolderOwner;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
/** 1.21.1 structure planning/density rules adapted to modern placement classes. */
public final class PortStructurePages {
private static final Map<Identifier, Identifier> SETS = Map.ofEntries(
vanillaSet("villages", "villages"),
vanillaSet("mineshafts", "mineshafts"),
vanillaSet("strongholds", "strongholds"),
vanillaSet("trial_chambers", "trial_chambers"),
vanillaSet("buried_treasure", "buried_treasures"),
vanillaSet("shipwrecks", "shipwrecks"),
vanillaSet("ocean_ruins", "ocean_ruins"),
vanillaSet("pillager_outposts", "pillager_outposts"),
vanillaSet("ruined_portals", "ruined_portals"),
vanillaSet("nether_fossils", "nether_fossils"),
vanillaSet("ancient_cities", "ancient_cities"),
vanillaSet("trail_ruins", "trail_ruins"),
vanillaSet("abandoned_camps", "abandoned_camp"),
Map.entry(page("nether_fortresses"), Identifier.fromNamespaceAndPath("rivencraft", "fortresses")),
Map.entry(page("bastions"), Identifier.fromNamespaceAndPath("rivencraft", "bastions")));
// Suppress vanilla's combined Nether set so an authored Fortress page does
// not also introduce Bastions (or vice versa).
private static final Set<Identifier> CONTROLLED_SETS = Stream.concat(SETS.values().stream(),
Stream.of(Identifier.withDefaultNamespace("nether_complexes")))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
private static final Set<Identifier> CAVE_ONLY = Set.of(
page("nether_fortresses"), page("bastions"), page("nether_fossils"));
private static final Set<Identifier> SURFACE_ONLY = Set.of(
page("villages"), page("strongholds"), page("buried_treasure"), page("shipwrecks"),
page("ocean_ruins"), page("pillager_outposts"), page("trail_ruins"), page("abandoned_camps"));
private static final Set<Identifier> FLAT_ALLOWED = Set.of(page("villages"), page("strongholds"));
private PortStructurePages() {}
private static Identifier page(String name) {
return Identifier.fromNamespaceAndPath("rivencraft", "feature/" + name);
}
private static Map.Entry<Identifier, Identifier> vanillaSet(String page, String set) {
return Map.entry(page(page), Identifier.withDefaultNamespace(set));
}
public static boolean supports(Identifier page) { return SETS.containsKey(page); }
public static void validateCounts(List<Identifier> pages) {
Map<Identifier, Integer> counts = new HashMap<>();
for (Identifier page : pages) {
if (!supports(page) || counts.merge(page, 1, Integer::sum) > 32)
throw new IllegalArgumentException("Structure plan requires supported pages, at most 32 per feature");
}
}
public static boolean supportsTerrain(Identifier page, PortAgeTerrain terrain) {
if (!supports(page) || terrain == PortAgeTerrain.VOID) return false;
if (terrain == PortAgeTerrain.FLAT && !FLAT_ALLOWED.contains(page)) return false;
if (CAVE_ONLY.contains(page)) return terrain == PortAgeTerrain.CAVE;
return terrain != PortAgeTerrain.CAVE || !SURFACE_ONLY.contains(page);
}
public static void validateRegistry(List<Identifier> pages, HolderLookup.Provider registries) {
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
pages.stream().filter(PortStructurePages::supports).distinct().forEach(page ->
sets.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)))
.orElseThrow(() -> new IllegalArgumentException("Missing structure set for " + page)));
}
/** Random choices need at least one normally eligible biome; authored pages may override it. */
public static boolean randomEligible(Identifier page, PortAgeTerrain terrain, Set<Identifier> biomes,
HolderLookup.Provider registries) {
if (!supportsTerrain(page, terrain)) return false;
var set = registries.lookupOrThrow(Registries.STRUCTURE_SET)
.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)));
if (set.isEmpty()) return false;
var compatible = compatibleBiomes(set.get().value());
return compatible.isEmpty() || biomes.stream().anyMatch(compatible::contains);
}
public static Set<Identifier> compatibleBiomes(Identifier page, HolderLookup.Provider registries) {
if (!supports(page)) return Set.of();
return registries.lookupOrThrow(Registries.STRUCTURE_SET)
.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)))
.map(holder -> compatibleBiomes(holder.value())).orElse(Set.of());
}
private static Set<Identifier> compatibleBiomes(StructureSet set) {
return set.structures().stream()
.flatMap(entry -> entry.structure().value().biomes().stream())
.flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier())
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
private static Map<Identifier, Integer> enabled(List<Identifier> pages) {
Map<Identifier, Integer> result = new HashMap<>();
for (Identifier page : pages) result.merge(SETS.get(page), 1, Integer::sum);
return result;
}
public static boolean authored(Structure structure, HolderLookup.Provider registries, List<Identifier> pages) {
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
return enabled(pages).keySet().stream()
.map(id -> sets.getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, id)).value())
.flatMap(set -> set.structures().stream()).anyMatch(entry -> entry.structure().value() == structure);
}
/** Extra possible biomes affect planning only; terrain still uses its original source. */
public static BiomeSource planningBiomes(BiomeSource actual, HolderLookup<StructureSet> sets, List<Identifier> pages) {
List<Holder<Biome>> extra = enabled(pages).keySet().stream()
.map(id -> sets.getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, id)).value())
.flatMap(set -> set.structures().stream()).flatMap(entry -> entry.structure().value().biomes().stream())
.distinct().toList();
return new BiomeSource() {
@Override protected MapCodec<? extends BiomeSource> codec() {
throw new UnsupportedOperationException("Planning biome sources are never serialized");
}
@Override protected Stream<Holder<Biome>> collectPossibleBiomes() {
return Stream.concat(actual.possibleBiomes().stream(), extra.stream());
}
@Override public net.minecraft.world.level.biome.BiomeResolver createResolver(Climate.Sampler sampler) {
return actual.createResolver(sampler);
}
};
}
public static HolderLookup<StructureSet> filtered(HolderLookup<StructureSet> source, List<Identifier> pages,
BiomeSource actual) {
Map<Identifier, Integer> counts = enabled(pages);
Map<ResourceKey<StructureSet>, Holder.Reference<StructureSet>> copies = new HashMap<>();
return new HolderLookup<>() {
@Override public Optional<Holder.Reference<StructureSet>> get(ResourceKey<StructureSet> key) {
if (CONTROLLED_SETS.contains(key.identifier()) && !counts.containsKey(key.identifier())) return Optional.empty();
return source.get(key).filter(holder -> counts.containsKey(key.identifier()) || hasBiome(holder.value(), actual))
.map(holder -> copies.computeIfAbsent(key, ignored -> {
int count = counts.getOrDefault(key.identifier(), 1);
return count == 1 ? holder : new LocalReference(key, scaled(holder.value(), count));
}));
}
@Override public Optional<HolderSet.Named<StructureSet>> get(TagKey<StructureSet> key) { return source.get(key); }
@Override public Stream<Holder.Reference<StructureSet>> listElements() {
return source.listElements().flatMap(holder -> get(holder.key()).stream());
}
@Override public Stream<HolderSet.Named<StructureSet>> listTags() { return source.listTags(); }
};
}
private static boolean hasBiome(StructureSet set, BiomeSource source) {
Set<Holder<Biome>> possible = source.possibleBiomes();
return set.structures().stream().flatMap(entry -> entry.structure().value().biomes().stream()).anyMatch(possible::contains);
}
private static StructureSet scaled(StructureSet set, int count) {
var placement = set.placement();
if (!(placement instanceof RandomSpreadStructurePlacement)
&& !(placement instanceof ConcentricRingsStructurePlacement)) return set;
StructurePlacementAccessor settings = (StructurePlacementAccessor) placement;
if (placement instanceof RandomSpreadStructurePlacement random) {
float original = settings.rivencraft$frequency();
float frequency = Math.min(1.0F, original * count);
double frequencyFactor = original > 0 ? frequency / original : 1;
int spacing = Math.max(1, (int) Math.round(random.spacing() / Math.sqrt(count / frequencyFactor)));
int separation = spacing == 1 ? 0 : Math.min(spacing - 1,
(int) Math.round((double) random.separation() * spacing / random.spacing()));
return new StructureSet(set.structures(), new RandomSpreadStructurePlacement(placement.locateOffset(),
settings.rivencraft$frequencyReductionMethod(), frequency, settings.rivencraft$salt(),
settings.rivencraft$exclusionZone(), spacing, separation, random.spreadType()));
}
if (placement instanceof ConcentricRingsStructurePlacement rings) {
int total = (int) Math.min(4095, (long) rings.count() * count);
int spread = Math.min(total, (int) Math.min(1023, (long) rings.spread() * count));
return new StructureSet(set.structures(), new ConcentricRingsStructurePlacement(placement.locateOffset(),
settings.rivencraft$frequencyReductionMethod(), settings.rivencraft$frequency(),
settings.rivencraft$salt(), settings.rivencraft$exclusionZone(), rings.distance(), spread,
total, rings.preferredBiomes()));
}
return set;
}
private static final class LocalReference extends Holder.Reference<StructureSet> {
private static final HolderOwner<StructureSet> OWNER = new HolderOwner<>() {};
private LocalReference(ResourceKey<StructureSet> key, StructureSet value) { super(Type.STAND_ALONE, OWNER, key, value); }
}
}
@@ -0,0 +1,142 @@
package net.dnorberg.rivencraft.age;
import java.util.Random;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/**
* 26.3 adaptation of Rivencraft 1.21.1's winding solid volumes, originally
* adapted from Mystcraft Legacy's SymbolTendrils and MapGenCavesMyst (LGPL-3.0-or-later).
* Each chunk reconstructs the neighboring source paths to preserve continuous borders.
*/
final class PortTendrilFeature {
private static final long SALT = 0x54454E4452494C53L;
private static final int SOURCE_RANGE = 8;
private static final int SOURCE_RATE = 15;
private static final int SOURCE_SIZE = 18;
private PortTendrilFeature() {}
static void generate(ChunkAccess chunk, long ageSeed, BlockState material) {
var target = chunk.getPos();
Random master = new Random(ageSeed ^ SALT);
long xSeed = master.nextLong();
long zSeed = master.nextLong();
for (int sourceX = target.x() - SOURCE_RANGE; sourceX <= target.x() + SOURCE_RANGE; sourceX++) {
for (int sourceZ = target.z() - SOURCE_RANGE; sourceZ <= target.z() + SOURCE_RANGE; sourceZ++) {
Random source = new Random((long) sourceX * xSeed ^ (long) sourceZ * zSeed ^ ageSeed ^ SALT);
int nodes = source.nextInt(source.nextInt(source.nextInt(SOURCE_SIZE) + 1) + 1);
if (source.nextInt(SOURCE_RATE) != 0) continue;
for (int node = 0; node < nodes; node++) {
double x = sourceX * 16 + source.nextInt(16);
double y = source.nextInt(source.nextInt(120) + 8);
double z = sourceZ * 16 + source.nextInt(16);
int branches = 1;
if (source.nextInt(4) == 0) {
trace(chunk, material, source.nextLong(), x, y, z,
1.0F + source.nextFloat() * 6.0F, 0.0F, 0.0F, -1, -1, 0.5D);
branches += source.nextInt(4);
}
for (int branch = 0; branch < branches; branch++) {
float yaw = source.nextFloat() * Mth.TWO_PI;
float pitch = (source.nextFloat() - 0.5F) / 4.0F;
float radius = source.nextFloat() * 2.0F + source.nextFloat();
if (source.nextInt(10) == 0)
radius *= source.nextFloat() * source.nextFloat() * 3.0F + 1.0F;
trace(chunk, material, source.nextLong(), x, y, z,
radius, yaw, pitch, 0, 0, 1.0D);
}
}
}
}
}
private static void trace(ChunkAccess chunk, BlockState material, long seed,
double x, double y, double z, float radius, float yaw, float pitch,
int step, int length, double verticalScale) {
Random random = new Random(seed);
if (length <= 0) {
int maximum = SOURCE_RANGE * 16 - 16;
length = maximum - random.nextInt(maximum / 4);
}
boolean singleNode = step == -1;
if (singleNode) step = length / 2;
int splitAt = random.nextInt(length / 2) + length / 4;
boolean slowPitch = random.nextInt(6) == 0;
float yawDrift = 0.0F;
float pitchDrift = 0.0F;
double middleX = chunk.getPos().getMinBlockX() + 8;
double middleZ = chunk.getPos().getMinBlockZ() + 8;
for (; step < length; step++) {
double horizontalRadius = 1.5D + Math.sin(step * Math.PI / length) * radius;
double verticalRadius = horizontalRadius * verticalScale;
float horizontalMotion = Mth.cos(pitch);
x += Mth.cos(yaw) * horizontalMotion;
y += Mth.sin(pitch);
z += Mth.sin(yaw) * horizontalMotion;
pitch *= slowPitch ? 0.92F : 0.7F;
pitch += pitchDrift * 0.1F;
yaw += yawDrift * 0.1F;
pitchDrift = pitchDrift * 0.9F
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 2.0F;
yawDrift = yawDrift * 0.75F
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 4.0F;
if (!singleNode && step == splitAt && radius > 1.0F) {
trace(chunk, material, random.nextLong(), x, y, z, random.nextFloat() * 0.5F + 0.5F,
yaw - Mth.HALF_PI, pitch / 3.0F, step, length, 1.0D);
trace(chunk, material, random.nextLong(), x, y, z, random.nextFloat() * 0.5F + 0.5F,
yaw + Mth.HALF_PI, pitch / 3.0F, step, length, 1.0D);
return;
}
if (!singleNode && random.nextInt(4) == 0) continue;
double distanceX = x - middleX;
double distanceZ = z - middleZ;
double remaining = length - step;
double reach = radius + 18.0D;
if (distanceX * distanceX + distanceZ * distanceZ - remaining * remaining > reach * reach) return;
if (x >= middleX - 16.0D - horizontalRadius * 2.0D
&& x <= middleX + 16.0D + horizontalRadius * 2.0D
&& z >= middleZ - 16.0D - horizontalRadius * 2.0D
&& z <= middleZ + 16.0D + horizontalRadius * 2.0D) {
fillNode(chunk, material, x, y, z, horizontalRadius, verticalRadius);
}
if (singleNode) break;
}
}
private static void fillNode(ChunkAccess chunk, BlockState material, double centerX, double centerY,
double centerZ, double horizontalRadius, double verticalRadius) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
int minX = Math.max(0, Mth.floor(centerX - horizontalRadius) - originX - 1);
int maxX = Math.min(16, Mth.floor(centerX + horizontalRadius) - originX + 2);
int minZ = Math.max(0, Mth.floor(centerZ - horizontalRadius) - originZ - 1);
int maxZ = Math.min(16, Mth.floor(centerZ + horizontalRadius) - originZ + 2);
int minY = Math.max(chunk.getMinY() + 1, Mth.floor(centerY - verticalRadius) - 1);
int maxY = Math.min(chunk.getMaxY(), Mth.floor(centerY + verticalRadius) + 2);
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int worldY = minY; worldY < maxY; worldY++) {
double dy = (worldY + 0.5D - centerY) / verticalRadius;
if (dy <= -0.7D || dy * dy >= 1.0D) continue;
for (int localZ = minZ; localZ < maxZ; localZ++) {
double dz = (originZ + localZ + 0.5D - centerZ) / horizontalRadius;
for (int localX = minX; localX < maxX; localX++) {
double dx = (originX + localX + 0.5D - centerX) / horizontalRadius;
if (dx * dx + dy * dy + dz * dz >= 1.0D) continue;
pos.set(originX + localX, worldY, originZ + localZ);
BlockState previous = chunk.getBlockState(pos);
if (previous == material || previous.is(Blocks.BEDROCK)) continue;
chunk.setBlockState(pos, material);
surface.update(localX, worldY, localZ, material);
floor.update(localX, worldY, localZ, material);
}
}
}
}
}
@@ -0,0 +1,102 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.HashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** Frozen terrain filters and ordered solid/surface features for an Age. */
public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
boolean structureControl) {
public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/floating_islands");
public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils");
public static final Identifier SKYLANDS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/skylands");
public static final Identifier SPHERES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/spheres");
public static final Identifier SPIKES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/spikes");
public static final Identifier OBELISKS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/obelisks");
public static final Identifier CRYSTAL_FORMATIONS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/crystal_formations");
public static final Identifier CRYSTAL_MATERIAL = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "crystal_block");
public static final Identifier DENSE_ORES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/dense_ores");
public static final Identifier STAR_FISSURES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/star_fissures");
public static final int MAX_PER_FEATURE = 32;
public static final Codec<PortTerrainFeaturePlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Choice.CODEC.listOf().fieldOf("choices").forGetter(PortTerrainFeaturePlan::choices),
Codec.BOOL.optionalFieldOf("skylands", false).forGetter(PortTerrainFeaturePlan::skylands),
Codec.intRange(0, MAX_PER_FEATURE).optionalFieldOf("dense_ores", 0)
.forGetter(PortTerrainFeaturePlan::denseOres),
Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures),
Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures),
Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers),
Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl)
).apply(instance, PortTerrainFeaturePlan::new));
public record Choice(Identifier page, Identifier material) {
public static final Codec<Choice> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("page").forGetter(Choice::page),
Identifier.CODEC.fieldOf("material").forGetter(Choice::material)
).apply(instance, Choice::new));
public Choice {
if (!isSupported(page) || material == null)
throw new IllegalArgumentException("Invalid terrain feature choice");
if (CRYSTAL_FORMATIONS.equals(page) && !CRYSTAL_MATERIAL.equals(material))
throw new IllegalArgumentException("Crystal formations use Rivencraft crystal, not a Block modifier");
}
}
public PortTerrainFeaturePlan {
choices = List.copyOf(choices);
structures = List.copyOf(structures);
carvers = List.copyOf(carvers);
PortStructurePages.validateCounts(structures);
PortCarverPages.validateCounts(carvers);
if (denseOres < 0 || denseOres > MAX_PER_FEATURE)
throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences");
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl)
throw new IllegalArgumentException("Terrain feature plan must not be empty");
var counts = new HashMap<Identifier, Integer>();
for (Choice choice : choices) {
if (counts.merge(choice.page(), 1, Integer::sum) > MAX_PER_FEATURE)
throw new IllegalArgumentException("Terrain feature plan permits at most 32 occurrences per feature");
}
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers) {
this(choices, skylands, denseOres, starFissures, structures, carvers, false);
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures) {
this(choices, skylands, denseOres, starFissures, structures, List.of());
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres, boolean starFissures) {
this(choices, skylands, denseOres, starFissures, List.of());
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres) {
this(choices, skylands, denseOres, false);
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands) { this(choices, skylands, 0, false); }
public PortTerrainFeaturePlan(List<Choice> choices) { this(choices, false, 0, false); }
public static boolean isSupported(Identifier page) {
return FLOATING_ISLANDS.equals(page) || TENDRILS.equals(page) || SPHERES.equals(page) || isSurface(page);
}
public static boolean acceptsMaterial(Identifier page) {
return isSupported(page) && !CRYSTAL_FORMATIONS.equals(page);
}
public static boolean isSurface(Identifier page) {
return SPIKES.equals(page) || OBELISKS.equals(page) || CRYSTAL_FORMATIONS.equals(page);
}
}
@@ -0,0 +1,56 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** Ordered, frozen plants and log decorations; not all require surface terrain. */
public record PortVegetationPlan(List<Identifier> pages) {
public static final Identifier FLOWERS = page("flowers");
public static final Identifier TREES = page("trees");
public static final Identifier HUGE_TREES = page("huge_trees");
public static final Identifier POPLAR_TREES = page("poplar_trees");
public static final Identifier FALLEN_POPLAR_TREES = page("fallen_poplar_trees");
public static final Identifier RED_SHRUBS = page("red_shrubs");
public static final Identifier SHELF_MUSHROOMS = page("shelf_mushrooms");
public static final Codec<PortVegetationPlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.listOf().fieldOf("pages").forGetter(PortVegetationPlan::pages)
).apply(instance, PortVegetationPlan::new));
public PortVegetationPlan {
pages = List.copyOf(pages);
if (pages.isEmpty() || pages.size() > 32 || pages.stream().anyMatch(page -> !isVegetation(page)))
throw new IllegalArgumentException("Vegetation plan requires 1–32 supported pages");
}
public static boolean isVegetation(Identifier page) {
return page.equals(FLOWERS) || page.equals(TREES) || page.equals(HUGE_TREES)
|| page.equals(POPLAR_TREES) || page.equals(FALLEN_POPLAR_TREES)
|| page.equals(RED_SHRUBS) || page.equals(SHELF_MUSHROOMS);
}
public static Identifier placedFeature(Identifier page) {
if (page.equals(FLOWERS)) return Identifier.withDefaultNamespace("flower_default");
if (page.equals(TREES)) return Identifier.withDefaultNamespace("trees_plains");
if (page.equals(HUGE_TREES)) return Identifier.withDefaultNamespace("trees_old_growth_spruce_taiga");
if (page.equals(POPLAR_TREES)) return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "poplar_trees");
if (page.equals(FALLEN_POPLAR_TREES)) return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "fallen_poplar_trees");
if (page.equals(RED_SHRUBS)) return Identifier.withDefaultNamespace("patch_red_shrub");
throw new IllegalArgumentException("Unsupported vegetation page " + page);
}
public static boolean addsLogs(Identifier page) {
return page.equals(TREES) || page.equals(HUGE_TREES)
|| page.equals(POPLAR_TREES) || page.equals(FALLEN_POPLAR_TREES);
}
public static boolean requiresSurface(Identifier page) {
return isVegetation(page) && !page.equals(SHELF_MUSHROOMS);
}
private static Identifier page(String name) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/" + name);
}
}
@@ -0,0 +1,28 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** The six active weather pages in the 1.21.1 writing system. */
public enum PortWeatherMode {
NORMAL, FAST, SLOW, OFF, ALWAYS, STORM;
public static final Codec<PortWeatherMode> CODEC = Codec.STRING.xmap(
name -> valueOf(name.toUpperCase(java.util.Locale.ROOT)),
mode -> mode.name().toLowerCase(java.util.Locale.ROOT));
public static PortWeatherMode forPage(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
return switch (page.getPath()) {
case "weather/normal" -> NORMAL;
case "weather/fast" -> FAST;
case "weather/slow" -> SLOW;
case "weather/off" -> OFF;
case "weather/always" -> ALWAYS;
case "weather/storm" -> STORM;
default -> null;
};
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Age-local policy; native WeatherData separately persists its live cycle. */
final class PortWeatherSettings extends SavedData {
private static final Codec<PortWeatherSettings> CODEC = PortWeatherMode.CODEC
.fieldOf("mode").codec().xmap(PortWeatherSettings::new, settings -> settings.mode);
static final SavedDataType<PortWeatherSettings> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "weather_policy"),
() -> new PortWeatherSettings(PortWeatherMode.NORMAL), CODEC);
private PortWeatherMode mode;
private PortWeatherSettings(PortWeatherMode mode) { this.mode = mode; }
PortWeatherMode mode() { return mode; }
void setMode(PortWeatherMode mode) {
this.mode = mode;
setDirty();
}
}
@@ -0,0 +1,98 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Disposable server fixture for copying transactions, page moves and two-part removal. */
final class PortWritingDeskProbe {
private static final BlockPos POS = new BlockPos(0, 64, 0);
private PortWritingDeskProbe() {}
private static ItemStack page(String path) { return PageItem.createSymbolPage(Identifier.fromNamespaceAndPath("rivencraft", path)); }
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("f92ad9ee-1ff0-4fe2-bcd3-994d4c035933"), "RivenDeskProbe"));
player.setPos(0.5, 64, 2.5);
// New Minecraft drops Container block-entity contents during removal.
// Removing either half must remove its partner and drop contents once.
for (Direction facing : Direction.Plane.HORIZONTAL) for (boolean extension : List.of(false, true)) {
var state = Rivencraft.WRITING_DESK.get().defaultBlockState().setValue(WritingDeskBlock.FACING, facing);
level.setBlock(POS, state, 3);
Rivencraft.WRITING_DESK.get().setPlacedBy(level, POS, state, player, new ItemStack(Rivencraft.WRITING_DESK_ITEM.get()));
BlockPos other = POS.relative(facing.getClockWise());
if (!level.getBlockState(other).getValue(WritingDeskBlock.EXTENSION))
throw new IllegalStateException("Desk extension not placed");
var desk = (WritingDeskBlockEntity) level.getBlockEntity(POS);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 3));
level.destroyBlock(extension ? other : POS, true);
if (level.getBlockState(POS).is(Rivencraft.WRITING_DESK.get()) || level.getBlockState(other).is(Rivencraft.WRITING_DESK.get()))
throw new IllegalStateException("Desk partner survived removal");
var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,
new net.minecraft.world.phys.AABB(-3, 60, -3, 4, 69, 4));
int paper = drops.stream().filter(e -> e.getItem().is(Items.PAPER)).mapToInt(e -> e.getItem().getCount()).sum();
int desks = drops.stream().filter(e -> e.getItem().is(Rivencraft.WRITING_DESK_ITEM.get())).mapToInt(e -> e.getItem().getCount()).sum();
if (paper != 3 || desks != 1) throw new IllegalStateException("Desk removal duplicated/lost drops: " + paper + "/" + desks);
drops.forEach(net.minecraft.world.entity.Entity::discard);
}
var state = Rivencraft.WRITING_DESK.get().defaultBlockState();
level.setBlock(POS, state, 3);
Rivencraft.WRITING_DESK.get().setPlacedBy(level, POS, state, player, new ItemStack(Rivencraft.WRITING_DESK_ITEM.get()));
var desk = (WritingDeskBlockEntity) level.getBlockEntity(POS);
try {
if (desk.canPlaceItem(0, PageItem.createLinkPanelPage())) throw new IllegalStateException("Desk accepted Link Panel");
desk.setItem(0, page("terrain/flat"));
var folder = new ItemStack(Rivencraft.COLLATION_FOLDER.get());
folder.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(page("weather/off"))));
desk.setItem(1, folder);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(Rivencraft.INK_VIAL.get()));
if (desk.inkRemaining() != 16 || !desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE))
throw new IllegalStateException("Desk ink exchange regression");
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
menu.broadcastChanges();
if (menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty() || desk.inkRemaining() != 16)
throw new IllegalStateException("Desk virtual result consumed inputs");
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ContainerInput.PICKUP, player);
if (menu.getCarried().isEmpty() || desk.inkRemaining() != 15
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(0).isEmpty())
throw new IllegalStateException("Desk copy did not consume exactly one paper/ink");
menu.setCarried(ItemStack.EMPTY);
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
if (!menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty() || desk.inkRemaining() != 15)
throw new IllegalStateException("Full inventory consumed copy inputs");
player.getInventory().clearContent();
if (!menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(1, 0, 1, 26)))
throw new IllegalStateException("Desk sparse page movement failed");
if (!menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(1, 26, -1)) || menu.getCarried().isEmpty())
throw new IllegalStateException("Desk page extraction failed");
menu.setCarried(ItemStack.EMPTY);
for (int i = 0; i < 21; i++) menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
if (menu.firstVisibleTab() != 84 || !menu.clickMenuButton(player, 87)) throw new IllegalStateException("88 desk tabs not reachable");
desk.setItem(87, page("lighting/bright"));
menu.clickMenuButton(player, WritingDeskMenu.PAGE_BUTTON_BASE);
menu.broadcastChanges();
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(POS) instanceof WritingDeskBlockEntity desk)
|| desk.activeCollection() != 87 || desk.inkRemaining() != 15
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(87).isEmpty()
|| desk.displayPage().isEmpty()) throw new IllegalStateException("Desk persistence regression");
}
}
@@ -0,0 +1,516 @@
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.Optional;
import java.util.OptionalLong;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
import net.minecraft.core.registries.Registries;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
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.biome.BiomeSource;
import net.minecraft.world.level.biome.FixedBiomeSource;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.Biomes;
import net.minecraft.world.level.biome.CheckerboardColumnBiomeSource;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.FlatLevelSource;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
import net.minecraft.world.level.levelgen.flat.FlatLayerInfo;
import net.minecraft.world.level.block.Blocks;
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(), index.biomePlan(age.getKey()),
index.terrain(age.getKey()), index.rocks(age.getKey()), index.vegetation(age.getKey()),
index.dungeons(age.getKey()), index.lakes(age.getKey()), index.terrainFeatures(age.getKey()));
} 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) {
return create(server, id, seed, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed, PortBiomePlan biomePlan) {
return create(server, id, seed, biomePlan, PortAgeTerrain.STANDARD);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain) {
return create(server, id, seed, biomePlan, terrain, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks) {
return create(server, id, seed, biomePlan, terrain, rocks, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, 0);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, PortWeatherMode.NORMAL);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, PortLighting.NORMAL);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, lighting, PortSkyStyle.OVERWORLD);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, lighting, sky, PortAgeColors.NATIVE);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, lighting, sky, colors, PortDayCycle.NATIVE);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle) {
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, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky, colors, dayCycle);
((PortAgeLevel) level).configureWeather(weather);
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
// 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, index(server).biomePlan(id), index(server).terrain(id),
index(server).rocks(id), index(server).vegetation(id), index(server).dungeons(id), index(server).lakes(id),
index(server).terrainFeatures(id));
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);
PortBiomePlan biomePlan = index.biomePlan(id);
PortAgeTerrain terrain = index.terrain(id);
PortRockPlan rocks = index.rocks(id);
PortVegetationPlan vegetation = index.vegetation(id);
int dungeons = index.dungeons(id);
PortLakePlan lakes = index.lakes(id);
PortTerrainFeaturePlan terrainFeatures = index.terrainFeatures(id);
if (previousSeed == seed) return;
boolean loaded = server.getLevel(dimensionKey(id)) != null;
if (loaded) unloadLoaded(server, id);
try {
if (loaded) load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
index.reseed(id, seed);
server.overworld().getDataStorage().saveAndJoin();
if (loaded) AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException exception) {
if (loaded) {
try {
load(server, id, previousSeed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
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);
PortBiomePlan biomePlan = index.biomePlan(id);
PortAgeTerrain terrain = index.terrain(id);
PortRockPlan rocks = index.rocks(id);
PortVegetationPlan vegetation = index.vegetation(id);
int dungeons = index.dungeons(id);
PortLakePlan lakes = index.lakes(id);
PortTerrainFeaturePlan terrainFeatures = index.terrainFeatures(id);
boolean loaded = server.getLevel(dimensionKey(id)) != null;
ArchivePaths paths = archivePaths(server, name);
PortLighting lighting = index.lighting(id);
PortSkyStyle sky = index.sky(id);
PortAgeColors colors = index.colors(id);
PortDayCycle dayCycle = index.dayCycle(id);
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, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky, colors, dayCycle);
server.overworld().getDataStorage().saveAndJoin();
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
if (loaded) {
try {
load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
} 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();
}
public static Map<Identifier, PortAgeColors> savedColors(MinecraftServer server) {
checkServerThread(server);
return index(server).colors();
}
public static Map<Identifier, PortDayCycle> savedDayCycles(MinecraftServer server) {
checkServerThread(server);
return index(server).dayCycles();
}
/** Includes retired IDs, which must never be silently reused by a new book. */
public static boolean isNameTaken(MinecraftServer server, Identifier id) {
checkServerThread(server);
ageName(id);
return index(server).contains(id);
}
private static AgeLevelIndex index(MinecraftServer server) {
return server.overworld().getDataStorage().computeIfAbsent(AgeLevelIndex.TYPE);
}
private static void checkServerThread(MinecraftServer server) {
if (!server.isSameThread()) throw new IllegalStateException("Runtime Age operation outside server thread");
}
private static long requireSeed(MinecraftServer server, Identifier id) {
ageName(id);
Long seed = index(server).seed(id);
if (seed == null) throw new IllegalArgumentException("Age not active: " + id);
return seed;
}
private static String ageName(Identifier id) {
if (!id.getNamespace().equals(Rivencraft.MOD_ID) || !id.getPath().startsWith("age/")) {
throw new IllegalArgumentException("Expected a rivencraft:age/* dimension ID: " + id);
}
String name = id.getPath().substring("age/".length());
if (name.isBlank() || name.contains("/") || name.equals(".") || name.equals("..")) {
throw new IllegalArgumentException("Expected a single safe Age path segment: " + id);
}
return name;
}
private static ResourceKey<Level> dimensionKey(Identifier id) {
return ResourceKey.create(Registries.DIMENSION, id);
}
private static void unloadLoaded(MinecraftServer server, Identifier id) {
ResourceKey<Level> key = dimensionKey(id);
ServerLevel level = server.getLevel(key);
if (level == null) throw new IllegalStateException("Age not loaded: " + id);
if (!level.players().isEmpty()) {
throw new IllegalStateException("Age still contains players; move them before unloading: " + id);
}
// Match the server shutdown sequence so forced tickets are persisted
// as inactive, rather than holding stale references to this level.
level.getChunkSource().deactivateTicketsOnClosing();
level.getChunkSource().tick(() -> true, false);
level.save(null, true, false);
try {
level.close();
} catch (IOException exception) {
throw new IllegalStateException("Could not close Age: " + id, exception);
}
server.levels.remove(key);
NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level));
Rivencraft.LOGGER.info("Unloaded 26.3 prototype Age {}", id);
}
private static ArchivePaths archivePaths(MinecraftServer server, String name) {
Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
Path dimensions = world.resolve("dimensions");
Path namespace = dimensions.resolve(Rivencraft.MOD_ID);
Path ageRoot = namespace.resolve("age");
Path live = ageRoot.resolve(name).normalize();
Path archiveRoot = world.getParent().resolve(world.getFileName() + "-retired-ages").normalize();
Path archive = archiveRoot.resolve(name).normalize();
if (!live.getParent().equals(ageRoot) || !archive.getParent().equals(archiveRoot)
|| Files.isSymbolicLink(world) || Files.isSymbolicLink(dimensions)
|| Files.isSymbolicLink(namespace) || Files.isSymbolicLink(ageRoot)
|| Files.isSymbolicLink(live) || Files.isSymbolicLink(archiveRoot)
|| Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException("Unsafe or existing Age archive path for " + name);
}
return new ArchivePaths(live, archive);
}
private record ArchivePaths(Path live, Path archive) {
}
private static BiomeSource authoredBiomeSource(MinecraftServer server, PortBiomePlan plan, long seed) {
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
List<Holder<Biome>> biomes = plan.biomes().stream().map(id ->
(Holder<Biome>) registry.get(ResourceKey.create(Registries.BIOME, id))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + id)))
.toList();
return switch (plan.distribution()) {
case NATIVE -> biomes.size() == 1 ? new FixedBiomeSource(biomes.getFirst())
: new PortScaledBiomeSource(HolderSet.direct(biomes), seed, 0);
case SINGLE -> new FixedBiomeSource(biomes.getFirst());
case TILED, GRID -> new CheckerboardColumnBiomeSource(HolderSet.direct(biomes),
plan.distribution() == PortBiomePlan.Distribution.GRID ? 0 : 4);
case TINY, SMALL, MEDIUM, LARGE, HUGE -> new PortScaledBiomeSource(
HolderSet.direct(biomes), seed, plan.distribution().ordinal() -
PortBiomePlan.Distribution.TINY.ordinal());
};
}
private static ServerLevel load(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures) {
return load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, index(server).lighting(id), index(server).sky(id), index(server).colors(id), index(server).dayCycle(id));
}
private static ServerLevel load(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky,
PortAgeColors colors, PortDayCycle dayCycle) {
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
if (biomePlan != null && biomePlan.distribution() == PortBiomePlan.Distribution.NATIVE
&& !biomePlan.biomes().isEmpty() && terrain != PortAgeTerrain.CAVE)
throw new IllegalArgumentException("Only Native Cave terrain may carry an explicit native biome pool");
// Noise terrains reuse the Overworld biome source unless a
// non-Native authored distribution or a frozen Native Cave pool replaces it.
// LevelStem.seedOverride controls chunk random state and getSeed().
LevelStem overworldStem = server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM)
.getValue(LevelStem.OVERWORLD);
if (overworldStem == null) throw new IllegalStateException("Missing Overworld LevelStem");
var generator = overworldStem.generator();
if (terrain == PortAgeTerrain.FLAT || terrain == PortAgeTerrain.VOID) {
var biomes = server.registryAccess().lookupOrThrow(Registries.BIOME);
Holder<Biome> biome = biomes.getOrThrow(terrain == PortAgeTerrain.VOID
? Biomes.THE_VOID : Biomes.PLAINS);
if (biomePlan != null && !biomePlan.biomes().isEmpty()) {
Identifier biomeId = biomePlan.biomes().getFirst();
biome = biomes.get(ResourceKey.create(Registries.BIOME, biomeId))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biomeId));
}
FlatLevelGeneratorSettings settings;
if (terrain == PortAgeTerrain.FLAT) {
settings = FlatLevelGeneratorSettings.getDefault(biomes,
server.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE));
// Vanilla superflat begins at the modern minimum build height.
// Pad the column with air so the first solid layer begins at Y=0.
int airBelowZero = Math.max(0, -server.overworld().getMinY());
if (airBelowZero > 0)
settings.getLayersInfo().addFirst(new FlatLayerInfo(airBelowZero, Blocks.AIR));
settings = settings.withBiomeAndLayers(settings.getLayersInfo(),
settings.structureOverrides(), biome);
settings.setDecoration();
} else {
settings = new FlatLevelGeneratorSettings(Optional.empty(), biome, List.of());
settings.updateLayers();
}
generator = biomePlan != null && biomePlan.biomes().size() > 1
? new PortFlatChunkGenerator(authoredBiomeSource(server, biomePlan, seed), settings)
: new FlatLevelSource(settings);
} else if ((biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE)
|| terrain != PortAgeTerrain.STANDARD) {
if (!(generator instanceof NoiseBasedChunkGenerator noise))
throw new IllegalStateException("Overworld generator cannot accept authored noise settings");
var source = terrain == PortAgeTerrain.CAVE
? new FixedBiomeSource(server.registryAccess().lookupOrThrow(Registries.BIOME)
.getOrThrow(Biomes.LUSH_CAVES))
: generator.getBiomeSource();
if (terrain == PortAgeTerrain.CAVE && biomePlan != null) {
var biomes = server.registryAccess().lookupOrThrow(Registries.BIOME);
for (Identifier biomeId : biomePlan.biomes()) {
var holder = biomes.get(ResourceKey.create(Registries.BIOME, biomeId))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biomeId));
if (!PortRegistryPages.isCaveBiome(holder))
throw new IllegalArgumentException("Cave terrain requires a Cave or Nether biome: " + biomeId);
}
}
if (biomePlan != null && (biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE
|| terrain == PortAgeTerrain.CAVE && !biomePlan.biomes().isEmpty())) {
source = authoredBiomeSource(server, biomePlan, seed);
}
var settings = switch (terrain) {
case STANDARD -> noise.generatorSettings();
case AMPLIFIED -> server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(NoiseGeneratorSettings.AMPLIFIED);
case ISLAND -> server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(NoiseGeneratorSettings.FLOATING_ISLANDS);
case CAVE -> dryCaveSettings(server);
case FLAT, VOID -> throw new IllegalStateException("Flat terrain should use FlatLevelSource");
};
generator = new NoiseBasedChunkGenerator(source, PortBiomeMaterialRules.apply(server.registryAccess(), settings.value()));
}
if (rocks != null || vegetation != null || dungeons > 0 || lakes != null || terrainFeatures != null)
generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed);
LevelStem stem = new LevelStem(lighting.dimensionType(server, terrain, overworldStem.type(), sky),
generator, OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
ServerLevel level = new PortAgeLevel(server, key, stem, biomeZoomSeed, terrain);
((PortAgeLevel) level).configureColors(colors);
((PortAgeLevel) level).configureDayCycle(dayCycle);
// 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;
}
}
private static Holder<NoiseGeneratorSettings> dryCaveSettings(MinecraftServer server) {
NoiseGeneratorSettings caves = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(NoiseGeneratorSettings.CAVES).value();
return Holder.direct(new NoiseGeneratorSettings(
caves.noiseSettings(), caves.defaultBlock(), Blocks.AIR.defaultBlockState(),
caves.noiseRouter(), caves.materialRule(), caves.spawnTarget(),
caves.noiseSettings().minY() - 1, caves.disableMobGeneration(), Optional.empty(),
caves.useLegacyRandomSource(), caves.debugFunctions()));
}
}
@@ -0,0 +1,45 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.block.AmethystBlock;
import net.minecraft.world.level.block.AmethystClusterBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.BuddingAmethystBlock;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.Fluids;
/** Vanilla amethyst growth, using Rivencraft's blue crystal stages. */
public final class BuddingCrystalBlock extends AmethystBlock {
public BuddingCrystalBlock(BlockBehaviour.Properties properties) {
super(properties);
}
@Override
protected void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
if (random.nextInt(5) != 0) return;
Direction direction = Direction.values()[random.nextInt(Direction.values().length)];
BlockPos target = pos.relative(direction);
BlockState existing = level.getBlockState(target);
Block next = nextStage(existing, direction);
if (next == null) return;
level.setBlockAndUpdate(target, next.defaultBlockState()
.setValue(AmethystClusterBlock.FACING, direction)
.setValue(AmethystClusterBlock.WATERLOGGED,
existing.getFluidState().getType() == Fluids.WATER));
}
public static Block nextStage(BlockState existing, Direction direction) {
if (BuddingAmethystBlock.canClusterGrowAtState(existing)) return Rivencraft.SMALL_CRYSTAL_BUD.get();
if (!existing.hasProperty(AmethystClusterBlock.FACING)
|| existing.getValue(AmethystClusterBlock.FACING) != direction) return null;
if (existing.is(Rivencraft.SMALL_CRYSTAL_BUD)) return Rivencraft.MEDIUM_CRYSTAL_BUD.get();
if (existing.is(Rivencraft.MEDIUM_CRYSTAL_BUD)) return Rivencraft.LARGE_CRYSTAL_BUD.get();
if (existing.is(Rivencraft.LARGE_CRYSTAL_BUD)) return Rivencraft.CRYSTAL_CLUSTER.get();
return null;
}
}
@@ -0,0 +1,17 @@
package net.dnorberg.rivencraft.block;
import net.minecraft.core.BlockPos;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** Solid crystal-textured arrival footing that cannot be collected. */
public final class CloudPlatformBlock extends Block {
public CloudPlatformBlock(Properties properties) { super(properties); }
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return ItemStack.EMPTY;
}
}
@@ -0,0 +1,47 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
/** 26.3 API adaptation of the 1.21.1 Ink Mixer block. */
public final class InkMixerBlock extends BaseEntityBlock {
public InkMixerBlock(Properties properties) { super(properties); }
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new InkMixerBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof InkMixerBlockEntity mixer) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new InkMixerMenu(id, inventory, mixer),
Component.translatable("container.rivencraft.ink_mixer")),
buffer -> buffer.writeBlockPos(pos));
}
return InteractionResult.SUCCESS;
}
@Override public void playerDestroy(ServerLevel level, ServerPlayer player, BlockPos pos,
BlockState state, BlockEntity entity, ItemStack tool) {
if (entity instanceof InkMixerBlockEntity mixer) Containers.dropContents(level, pos, mixer);
super.playerDestroy(level, player, pos, state, entity, tool);
}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.phys.BlockHitResult;
public final class PortBookBinderBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public PortBookBinderBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING);
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new PortBookBinderBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof PortBookBinderBlockEntity binder) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new PortBookBinderMenu(id, inventory, binder),
Component.translatable("container.rivencraft.book_binder")),
buffer -> {
buffer.writeBlockPos(pos);
buffer.writeUtf(binder.title(), 32);
});
}
return InteractionResult.SUCCESS;
}
@Override public void playerDestroy(ServerLevel level, ServerPlayer player, BlockPos pos,
BlockState state, BlockEntity entity, ItemStack tool) {
if (entity instanceof PortBookBinderBlockEntity binder) Containers.dropContents(level, pos, binder);
super.playerDestroy(level, player, pos, state, entity, tool);
}
}
@@ -0,0 +1,66 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.InsideBlockEffectApplier;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.EndPortalBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.portal.TeleportTransition;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
/** Non-obtainable starfield portal returning entities to the shared Overworld spawn. */
public final class StarFissureBlock extends EndPortalBlock {
private static final VoxelShape SHAPE = Block.box(0, 0, 0, 16, 1.6, 16);
public StarFissureBlock(BlockBehaviour.Properties properties) { super(properties); }
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new StarFissureBlockEntity(pos, state);
}
@Override
public void animateTick(BlockState state, Level level, BlockPos pos, net.minecraft.util.RandomSource random) {
// Legacy fissures have a starfield, not vanilla End portal smoke.
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return SHAPE;
}
@Override
protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity,
InsideBlockEffectApplier effects, boolean precise) {
// Do not inherit End credits or End-dimension travel from the vanilla block.
if (entity.canUsePortal(false)) entity.setAsInsidePortal(this, pos);
}
@Override
public TeleportTransition getPortalDestination(ServerLevel level, Entity entity, BlockPos pos) {
if (level.dimension() == Level.OVERWORLD) return null;
ServerLevel overworld = level.getServer().overworld();
if (overworld == null) return null;
BlockPos spawn = overworld.getRespawnData().pos();
BlockPos landing = net.dnorberg.rivencraft.age.PortAgeTravel.findFootedDestination(overworld, spawn).orElse(spawn);
level.playSound(null, pos, Rivencraft.LINK_FISSURE.get(), SoundSource.BLOCKS, 1.0F, 1.0F);
return new TeleportTransition(overworld, Vec3.atBottomCenterOf(landing), entity.getDeltaMovement(),
entity.getYRot(), entity.getXRot(), TeleportTransition.PLACE_PORTAL_TICKET.then(arrived -> {
// Departure audio can be discarded when the client switches levels.
// Play after arrival too so the traveler hears the successful return.
arrived.level().playSound(null, arrived.blockPosition(), Rivencraft.LINK_FISSURE.get(),
SoundSource.BLOCKS, 1.0F, 1.0F);
}));
}
}
@@ -0,0 +1,83 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.entity.LivingEntity;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.phys.BlockHitResult;
public final class WritingDeskBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public static final BooleanProperty EXTENSION = BooleanProperty.create("extension");
public WritingDeskBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(EXTENSION, false));
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING, EXTENSION);
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getHorizontalDirection().getOpposite();
BlockPos extension = context.getClickedPos().relative(facing.getClockWise());
return context.getLevel().getBlockState(extension).canBeReplaced(context)
? defaultBlockState().setValue(FACING, facing) : null;
}
@Override public void setPlacedBy(Level level, BlockPos pos, BlockState state,
@Nullable LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
level.setBlock(pos.relative(state.getValue(FACING).getClockWise()),
state.setValue(EXTENSION, true), 3);
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return state.getValue(EXTENSION) ? null : new WritingDeskBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
BlockPos controller = state.getValue(EXTENSION)
? pos.relative(state.getValue(FACING).getCounterClockWise()) : pos;
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(controller) instanceof WritingDeskBlockEntity desk) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new WritingDeskMenu(id, inventory, desk),
Component.translatable("container.rivencraft.writing_desk")),
buffer -> {
buffer.writeBlockPos(controller);
WritingDeskCatalog.write(buffer, WritingDeskCatalog.available(desk));
});
}
return InteractionResult.SUCCESS;
}
@Override protected void affectNeighborsAfterRemoval(BlockState state, net.minecraft.server.level.ServerLevel level,
BlockPos pos, boolean movedByPiston) {
BlockPos other = pos.relative(state.getValue(FACING).getClockWise(), state.getValue(EXTENSION) ? -1 : 1);
BlockState otherState = level.getBlockState(other);
if (otherState.is(this) && otherState.getValue(EXTENSION) != state.getValue(EXTENSION)
&& otherState.getValue(FACING) == state.getValue(FACING))
level.setBlock(other, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 35);
Containers.updateNeighboursAfterDestroy(state, level, pos);
}
}
@@ -0,0 +1,176 @@
package net.dnorberg.rivencraft.block.entity;
import java.util.Random;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** Server-owned Link Panel mixer with a persistent, Legacy-style additive mixture. */
public final class InkMixerBlockEntity extends BlockEntity implements Container {
public static final int INK = 0;
public static final int PAPER = 1;
public static final int BOTTLES = 2;
public static final int SIZE = 3;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int inkRemaining;
private long nextModifierSeed = new Random().nextLong();
private final double[] modifierChances = new double[3];
private static final int[] FLAGS = {LinkProperties.DISARM,
LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.INTRA_LINKING};
private static final String[] CHANCE_KEYS = {"DisarmChance", "MomentumChance", "IntraLinkingChance"};
private static final int[] OLD_CHANCE_INDEX = {0, 2, 3};
public InkMixerBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.INK_MIXER_BLOCK_ENTITY.get(), pos, state);
}
public int inkRemaining() { return inkRemaining; }
public int modifierChancePermille(int index) {
return Math.clamp((int) Math.round(modifierChances[index] * 1000), 0, 1000);
}
/** Infuses a carried ingredient into the current ink pool; only the server calls this. */
public boolean addModifier(ItemStack carried, int amount) {
InkModifierIngredients.Ingredient ingredient = InkModifierIngredients.find(carried);
if (ingredient == null || inkRemaining == 0 || amount <= 0 || carried.getCount() < amount)
return false;
for (InkModifierIngredients.Effect effect : ingredient.effects())
for (int index = 0; index < FLAGS.length; index++)
if (FLAGS[index] == effect.flag())
modifierChances[index] = 1.0 - (1.0 - modifierChances[index])
* Math.pow(1.0 - effect.chance(), amount);
carried.shrink(amount);
setChanged();
return true;
}
public ItemStack previewLinkPanel() {
if (!canCreateLinkPanel()) return ItemStack.EMPTY;
ItemStack panel = PageItem.createLinkPanelPage();
LinkProperties properties = rollProperties(nextModifierSeed).properties();
if (properties.flags() != 0) panel.set(Rivencraft.LINK_PROPERTIES.get(), properties);
return panel;
}
private record Roll(LinkProperties properties, long nextSeed) {}
private Roll rollProperties(long seed) {
Random random = new Random(seed);
int selected = 0;
for (int index = 0; index < FLAGS.length; index++)
if (modifierChances[index] > 0 && random.nextDouble() < modifierChances[index])
selected |= FLAGS[index];
return new Roll(new LinkProperties(selected), random.nextLong());
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) {
setChanged();
if (slot == BOTTLES) refillEmptyBasin();
}
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
setChanged();
if (slot == INK || slot == BOTTLES) refillEmptyBasin();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return switch (slot) {
case INK -> stack.is(Rivencraft.INK_VIAL.get());
case PAPER -> stack.is(Items.PAPER);
default -> false;
};
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
/** No server tick: refill only when ink, bottle space, or consumption changes. */
private void refillEmptyBasin() {
if (level != null && !level.isClientSide() && inkRemaining == 0) {
int addedInk = InkVialExchange.exchange(items, INK, BOTTLES);
if (addedInk > 0) {
inkRemaining = addedInk;
setChanged();
}
}
}
public boolean canCreateLinkPanel() {
if (!items.get(PAPER).is(Items.PAPER)) return false;
return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, 1);
}
/** Commit inputs only when a virtual output is actually taken. */
public boolean consumeLinkPanelMaterials() {
if (!canCreateLinkPanel()) return false;
int cost = 1;
if (inkRemaining < cost) {
inkRemaining += InkVialExchange.exchange(items, INK, BOTTLES);
}
inkRemaining -= cost;
items.get(PAPER).shrink(1);
if (items.get(PAPER).isEmpty()) items.set(PAPER, ItemStack.EMPTY);
nextModifierSeed = rollProperties(nextModifierSeed).nextSeed();
java.util.Arrays.fill(modifierChances, 0);
setChanged();
if (inkRemaining == 0) refillEmptyBasin();
return true;
}
@Override public void setLevel(Level level) {
super.setLevel(level);
refillEmptyBasin();
}
@Override public void clearContent() {
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
java.util.Arrays.fill(modifierChances, 0);
setChanged();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
inkRemaining = Math.clamp(input.getIntOr("InkRemaining", 0), 0, InkVialItem.CAPACITY);
nextModifierSeed = input.getLongOr("NextModifierSeed", nextModifierSeed);
for (int index = 0; index < modifierChances.length; index++) {
modifierChances[index] = Math.clamp(input.getDoubleOr(CHANCE_KEYS[index],
input.getDoubleOr("ModifierChance" + OLD_CHANCE_INDEX[index], 0.0)), 0.0, 1.0);
}
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
output.putInt("InkRemaining", inkRemaining);
output.putLong("NextModifierSeed", nextModifierSeed);
for (int index = 0; index < modifierChances.length; index++)
output.putDouble(CHANCE_KEYS[index], modifierChances[index]);
}
}
@@ -0,0 +1,171 @@
package net.dnorberg.rivencraft.block.entity;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.resources.Identifier;
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** Persistent, server-owned Binder inputs. */
public final class PortBookBinderBlockEntity extends BlockEntity implements Container {
public static final int COVER = 0;
public static final int PANEL = 1;
public static final int OUTPUT = 2;
public static final int FIRST_PAGE = 3;
public static final int PAGE_COUNT = 252;
public static final int SIZE = FIRST_PAGE + PAGE_COUNT;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private String title = "";
public PortBookBinderBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.BOOK_BINDER_BLOCK_ENTITY.get(), pos, state);
}
public String title() { return title; }
public void setTitle(String value) {
String normalized = value == null ? "" : value.strip();
if (normalized.length() > 32) return;
if (!title.equals(normalized)) {
title = normalized;
setChanged();
}
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public void clearContent() {
items.clear();
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
setChanged();
}
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) {
ItemStack removed = ContainerHelper.takeItem(items, slot);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
setChanged();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
if (slot == COVER) return stack.is(Items.LEATHER);
if (slot == PANEL) return PageItem.isLinkPanel(stack);
return slot >= FIRST_PAGE && slot < SIZE && validSymbolPage(stack);
}
private static boolean validSymbolPage(ItemStack stack) {
return stack.is(Rivencraft.PAGE.get()) && !PageItem.isLinkPanel(stack)
&& stack.has(Rivencraft.SYMBOL_PAGE_ID.get());
}
public boolean hasSymbolPages() {
for (int slot = FIRST_PAGE; slot < SIZE; slot++)
if (!items.get(slot).isEmpty()) return true;
return false;
}
public List<Identifier> symbolPages() {
List<Identifier> pages = new ArrayList<>();
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
Identifier symbol = items.get(slot).get(Rivencraft.SYMBOL_PAGE_ID.get());
if (symbol != null) pages.add(symbol);
}
return List.copyOf(pages);
}
public void clearSymbolPages() {
for (int slot = FIRST_PAGE; slot < SIZE; slot++) items.set(slot, ItemStack.EMPTY);
setChanged();
}
public int nextPageIndex() {
for (int index = PAGE_COUNT - 1; index >= 0; index--)
if (!items.get(FIRST_PAGE + index).isEmpty()) return index + 1;
return 0;
}
public boolean insertPage(int index, ItemStack page) {
int end = nextPageIndex();
if (index < 0 || index > end || end >= PAGE_COUNT || page.getCount() != 1
|| !validSymbolPage(page)) return false;
for (int current = end; current > index; current--)
items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current - 1));
items.set(FIRST_PAGE + index, page.copy());
setChanged();
return true;
}
/** Validate the complete collection before moving any page, as in 1.21.1. */
public ItemStack importCollection(ItemStack collection, int index) {
if (!net.dnorberg.rivencraft.item.CollationFolderItem.isCollection(collection)
|| collection.getCount() != 1) return ItemStack.EMPTY;
var contents = collection.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(),
net.dnorberg.rivencraft.writing.NotebookContents.EMPTY);
int end = nextPageIndex();
if (index < 0 || index > end) return ItemStack.EMPTY;
ItemStack panel = ItemStack.EMPTY;
List<ItemStack> writing = new ArrayList<>();
for (ItemStack page : contents.pages()) {
if (page.isEmpty()) continue;
if (!page.is(Rivencraft.PAGE.get()) || page.getCount() != 1) return ItemStack.EMPTY;
if (PageItem.isLinkPanel(page)) {
if (!panel.isEmpty() || !items.get(PANEL).isEmpty()) return ItemStack.EMPTY;
panel = page;
} else if (validSymbolPage(page)) writing.add(page);
else return ItemStack.EMPTY;
}
if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && panel.isEmpty()) return ItemStack.EMPTY;
if (!panel.isEmpty()) items.set(PANEL, panel.copy());
for (int current = end - 1; current >= index; current--)
items.set(FIRST_PAGE + current + writing.size(), items.get(FIRST_PAGE + current));
for (ItemStack page : writing) items.set(FIRST_PAGE + index++, page.copy());
ItemStack emptied = collection.copy();
emptied.set(Rivencraft.NOTEBOOK_CONTENTS.get(), net.dnorberg.rivencraft.writing.NotebookContents.EMPTY);
setChanged();
return emptied;
}
public ItemStack takePage(int index) {
int end = nextPageIndex();
if (index < 0 || index >= end) return ItemStack.EMPTY;
ItemStack removed = items.get(FIRST_PAGE + index);
for (int current = index; current < end - 1; current++)
items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current + 1));
items.set(FIRST_PAGE + end - 1, ItemStack.EMPTY);
setChanged();
return removed;
}
@Override public boolean stillValid(Player player) {
return level != null && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
output.putString("Title", title);
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
title = input.getStringOr("Title", "");
}
}
@@ -0,0 +1,12 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.entity.TheEndPortalBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
public final class StarFissureBlockEntity extends TheEndPortalBlockEntity {
public StarFissureBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), pos, state);
}
}
@@ -0,0 +1,173 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** Collection tabs, paper, ink vials and empty-vial return are owned by the block. */
public final class WritingDeskBlockEntity extends BlockEntity implements Container {
public static final int COLLECTIONS = 88;
public static final int INK = COLLECTIONS;
public static final int RETURN = INK + 1;
public static final int PAPER = INK + 2;
/** Unused by new desks; retained so old saves cannot silently lose slot contents. */
public static final int LEGACY_OUTPUT = INK + 3;
public static final int SIZE = INK + 4;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int activeCollection;
private int inkRemaining;
private ItemStack displayPage = ItemStack.EMPTY;
public WritingDeskBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), pos, state);
}
public int activeCollection() { return activeCollection; }
public int inkRemaining() { return inkRemaining; }
public ItemStack displayPage() { return displayPage; }
public void setDisplayPage(ItemStack page) {
ItemStack next = page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1);
if (!ItemStack.matches(displayPage, next)) {
displayPage = next;
setChanged();
}
}
public void setActiveCollection(int index) {
if (index >= 0 && index < COLLECTIONS && activeCollection != index) {
activeCollection = index;
setChanged();
}
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) {
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == RETURN) refillEmptyInkBar();
}
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == INK || slot == RETURN) refillEmptyInkBar();
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition)
&& level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
if (slot < COLLECTIONS) return CollationFolderItem.isCollection(stack)
&& stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|| stack.is(Rivencraft.PAGE.get())
&& PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
return switch (slot) {
case INK -> stack.is(Rivencraft.INK_VIAL.get());
case RETURN -> false;
case PAPER -> stack.is(Items.PAPER);
case LEGACY_OUTPUT -> false;
default -> false;
};
}
/** Consume one vial only when the empty meter can return its glass bottle. */
private void refillEmptyInkBar() {
if (level != null && !level.isClientSide() && inkRemaining == 0) {
int addedInk = InkVialExchange.exchange(items, INK, RETURN);
if (addedInk > 0) {
inkRemaining = addedInk;
setChanged();
}
}
}
/** The output is virtual; inputs are committed only after a result can be given to a player. */
public boolean canConsumeCopyMaterials() {
ItemStack paper = items.get(PAPER);
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get();
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
}
public boolean consumeCopyMaterials() {
if (!canConsumeCopyMaterials()) return false;
int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get();
if (inkRemaining < cost) {
inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
}
inkRemaining -= cost;
removeItem(PAPER, 1);
setChanged();
if (inkRemaining == 0) refillEmptyInkBar();
return true;
}
@Override public void setLevel(Level level) {
super.setLevel(level);
refillEmptyInkBar();
}
@Override public void clearContent() {
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
displayPage = ItemStack.EMPTY;
setChanged();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
activeCollection = Math.clamp(input.getIntOr("ActiveCollection", 0), 0, COLLECTIONS - 1);
inkRemaining = Math.clamp(input.getIntOr("InkRemaining", 0), 0, InkVialItem.CAPACITY);
displayPage = input.read("DisplayPage", ItemStack.CODEC).orElse(ItemStack.EMPTY);
refillEmptyInkBar();
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
output.putInt("ActiveCollection", activeCollection);
output.putInt("InkRemaining", inkRemaining);
if (!displayPage.isEmpty()) output.store("DisplayPage", ItemStack.CODEC, displayPage);
}
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
@Override public void setChanged() {
super.setChanged();
if (level != null && !level.isClientSide()) {
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
}
}
@@ -0,0 +1,44 @@
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 logout(net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent.LoggingOut event) {
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(java.util.Map.of());
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(java.util.Map.of());
}
@SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle);
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
if (!(context.listener() instanceof ClientPacketListener listener)) return;
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(payload.dayCycles());
if (listener.getLevel() != null) {
listener.getLevel().environmentAttributes().invalidateTickCache();
listener.getLevel().updateSkyBrightness();
}
listener.levels = payload.dimensions().stream()
.map(id -> ResourceKey.<Level>create(Registries.DIMENSION, id))
.collect(Collectors.toUnmodifiableSet());
context.reply(new AgeCatalogAckPayload(payload.version()));
});
}
}
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more