Compare commits

...
Author SHA1 Message Date
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
75 changed files with 3804 additions and 93 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/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

+89
View File
@@ -83,6 +83,12 @@ Age must retain its identity, seed, chunks and access across save/restart.
identify the derived files. Prefer the smallest backend Rivencraft needs
over a general-purpose library fork.
For gameplay systems, treat the 1.21.1 Rivencraft source and assets as the
porting baseline. Carry across unchanged rules, probabilities, recipes, and
textures directly; adapt only the Minecraft/NeoForge APIs that changed in
26.3. Record deliberate behavior gaps (such as prototype Age generation)
instead of silently replacing established mechanics with new ones.
The release gate is a 26.3 integrated-client **and** dedicated-server matrix:
create two Ages with distinct seeds, explore chunks, travel while another
client remains online, reconnect, restart, revisit saved chunks, reseed for
@@ -419,6 +425,89 @@ without carrying its Page identity onto the Book. No Link Panel modifiers are
ported yet. The player passed the in-game crafting check on 2026-10-29. The
full Book Binder GUI and Age authoring remain the next larger port step.
The local `1.0.1.10-alpha.1` iteration adds the first three Link Panel
modifiers as a prerequisite to Binder authoring. All eight combinations of
Disarm, Maintain Momentum, and Intra-Linking are testable in the Link Pages
creative tab and persist as a synced component through leather crafting and
book binding. The server uses the book's flags when linking: Intra-Linking
allows same-dimension travel, Maintain Momentum rotates horizontal velocity
to the destination yaw, and Disarm drops player inventory at the departure
point only after a successful teleport. The Ink Mixer, additional Link Modifier
flags, and Descriptive Book panel propagation are not yet ported. In-game
testing of each modifier and their combinations passed in game. This local slice
must not be committed, pushed or published without the player's explicit request.
The next local `1.0.1.11-alpha.1` slice introduces the Book Binder block and
its first server-authoritative binding workflow. Leather and a Link Panel
produce a named, bound Descriptive Book for a random prototype Age; the output
is persisted in the Binder until collected. The panel's Disarm and Maintain
Momentum flags carry onto the book and apply after successful Age travel;
Intra-Linking is inert for Descriptive Books. This is not yet symbol-page
authoring or the full Legacy Binder interface. Build and dedicated-server
smoke pass. The player passed basic Binder crafting and travel in game, but
reported that typing E closed the GUI and asked us to reuse the 1.21.1 GUI
more faithfully. No
commit, push, or publication without an explicit request during alpha.
The local `1.0.1.12-alpha.1` follow-up ports the existing Binder layout's
top title field, flank slots, center Page strip, and pulsing red missing-panel
cue. It persists the title on the block entity and consumes focused keyboard
events before the container can treat E as inventory-close. Click the output
once to bind and again to collect the result. The full authored-symbol strip
and 1.21.1 grammar still await later port slices. The same slice adapts the
existing 1.21.1 Ink Mixer block, persistent basin, additive probabilities,
16-ink-per-panel exchange, virtual output, Legacy atlas and animated glyph to
the 26.3 storage, menu and GUI APIs. The build and dedicated-server Age smoke
pass. In-game Binder and Mixer verification is
pending; do not commit, push, or publish without explicit authorization.
The local `1.0.1.13-alpha.1` iteration moves the Binder's freshly assembled
book directly to the cursor so one output click both binds and takes it. Its
central strip now displays five Legacy page thumbnails, while only the Link
Panel is currently writable. The Mixer recomputes its virtual output in the
same server click as an input change to address an intermittent first-attempt
delay. The user's second Mixer attempt succeeded but exposed a cost mismatch:
the Mixer now spends one ink and one paper per Link Panel and resets its
additive chances on output pickup. Build passes; in-game retest is
pending. This alpha remains local until explicitly authorized for release.
The local `1.0.1.14-alpha.1` visual follow-up uses the new Binder dev images
as a reference: the page strip now grows only when a Link Panel is present or
carried, its required-panel cue appears above the strip, and the first page
can be clicked to place/take the panel. The stored page has a tooltip. The
rest of the 1.21.1 strip (symbol pages and scrolling) depends on porting the
symbol-page data and authoring pipeline, not just drawing more placeholders.
The local `1.0.1.15-alpha.1` follow-up changes the missing-panel cue to the
Link Panel's 16×16 item icon while preserving its red pulse and tooltip.
The local `1.0.1.33-beta.1` slice adds Flat and Void terrain to authored Binder
books. Flat uses vanilla superflat layers and can use one authored Biome; Void
generates no terrain but can use one authored Biome for environmental behavior.
Without a Biome page, Void uses `minecraft:the_void`. Books with multiple Biome
pages and Flat/Void terrain remain unassembled until their custom distribution
generator is ported. The dedicated-server smoke creates, samples, and reloads
both terrain families in a disposable world.
The local `1.0.1.34-beta.1` follow-up moves Flat ground to Y=0 and targets its
surface for travel. `1.0.1.35-beta.1` enables the selected biome's decoration
on Flat. `1.0.1.36-beta.1` ports a multi-biome Flat/Void chunk generator and
accepts the existing authored distribution pages for those terrains. New Flat
chunks can vary vegetation by biome; Void keeps biome climate without adding
a floor or decorations. These are beta-port slices, not full 1.21.1 parity.
The local `1.0.1.37-beta.1` slice ports Cave terrain authoring using the
26.3 caves noise preset. Cave and Nether Biome pages can be distributed
through an Age, and travel searches for footing from Y=64 downward rather
than mistaking the cave roof for the surface. Nether-style cave material
replacement from 1.21.1 remains a separate port task. The local
`1.0.1.38-beta.1` follow-up
keeps Cave Ages dry by default: the vanilla 26.3 Cave preset otherwise fills
low openings with water. This changes newly generated chunks only; a future
Flooded page will deliberately set an authored water level in both maintained
versions.
The local `1.0.1.39-beta.1` follow-up makes the 26.3 landing search reject
fluid at feet and head height and prevents a submerged fallback platform.
## Verification of this first slice
The 1.21.1 `compileJava` and `build` tasks passed offline after the backend
+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.
+180 -16
View File
@@ -1,15 +1,16 @@
# Minecraft 26.3 port workspace
# Minecraft 26.3 beta port workspace
This is an **experimental alpha port** for issues #97–#104, not gameplay parity
with the Minecraft 1.21.1 release. It registers basic items and a runtime Age
prototype with persisted seeds. The operator-only `rivencraft_port age`
commands create and inspect separate vanilla-terrain levels. The current
development slice can name an unbound Descriptive Book to create a prototype
Age, then enter it from the book GUI; the player verified this interaction in game.
Named Linking Book binding/travel works as a limited probe. Authored
generation, most content, recipes, workstations, portal systems, modifiers and
the destination preview renderer remain unported. Use a test world and keep a
backup; do not substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
This is an **experimental beta port** for issues #97–#104, not gameplay parity
with the Minecraft 1.21.1 release. The 26.3 backend persists separate Ages and
their seeds. Books, the Book Binder, and the Ink Mixer work, and the Binder can
author Standard, Amplified, Island, Flat, Void, or Cave terrain with supported
biome-distribution pages. Other terrain families, feature/material effects,
portals, and much of
the 1.21.1 gameplay remain unported. Use a test world and keep a backup; do not
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
The entries below record how the port was built, including earlier alpha-only
limitations that were addressed by later slices.
The creative tab contains the two Books, Crystal Block and Crystal Shard;
the player verified these in game. Unbound Linking Books use the Legacy
@@ -23,12 +24,175 @@ using the existing item texture. Blank Pages remain unavailable in Creative;
the player verified the Link Panel's appearance in game. Ink Vials now use
the existing crafting recipe and animated sprite. With paper in the other
hand, an Ink Vial prepares a Link Panel for two of its sixteen ink uses, as
in the current 1.21.1 default. The Book Binder and symbol-writing systems
are still unported. The player verified the Ink Vial interaction in game.
in the current 1.21.1 default. The player verified the Ink Vial interaction
in game. Symbol writing remains unported.
A prepared Link Panel can now be combined shapelessly with leather to craft
an unwritten Linking Book, as in 1.21.1. This custom recipe rejects a blank
Page and extra ingredients. The Book Binder remains the next larger step for
assembling Descriptive Books from authored pages.
Page and extra ingredients. The first Book Binder slice now assembles random-Age
Descriptive Books from leather and a Link Panel. Authored pages remain later.
The next local `1.0.1.10-alpha.1` slice exposes all eight combinations of
plain, Disarm, Maintain Momentum, and Intra-Linking Link Panels in the Link
Pages creative tab. Leather crafting preserves those flags on the Linking
Book. Intra-Linking permits same-dimension travel; Maintain Momentum rotates
horizontal speed to the destination yaw; Disarm leaves the player's inventory
at the departure point after a successful teleport. These are development
variants until the Ink Mixer is ported; other Link Panel modifiers are not
implemented in 26.3. The player passed this modifier slice in game.
The local `1.0.1.11-alpha.1` slice adds a craftable Book Binder with persistent
leather, Link Panel, and output slots. Name an Age in its GUI and press Bind;
the server creates a random prototype Age and places its completed Descriptive
Book in the output. Disarm and Maintain Momentum carry from the panel into the
book; Intra-Linking has no effect on Age creation. Symbol-page authoring and
the Ink Mixer remain unported. This slice passes the build and dedicated-server
smoke test. The player passed crafting and travel in game, but found that E
closed the focused title field and the provisional layout did not match the
existing 1.21.1 Binder GUI.
The local `1.0.1.12-alpha.1` follow-up reuses the 1.21.1 Binder layout: title
bar, cover and output at opposite sides, and a central Link Panel page strip.
The title persists in the block, the missing-panel cue pulses red, and a
focused title field consumes inventory/hotbar keys. Click the output once to
bind, then click again to take the completed book. Authored symbol strips and
the full 1.21.1 Binder grammar remain unported. This same slice also ports the
1.21.1 Ink Mixer block, menu, animation, modifier additives, 16-ink-per-panel
cost, and automatic vial exchange. The creative tab includes it and its
vanilla-shaped recipe is available. This slice needs an in-game Binder visual/
keyboard check and Ink Mixer interaction check.
The local `1.0.1.13-alpha.1` iteration makes Binder assembly place the new
Descriptive Book directly on the cursor with one click. It also renders the
five-page strip from the 1.21.1 GUI and refreshes the Mixer's virtual result
in the same server click that changes paper or ink. The Mixer now consumes
one ink unit and one paper per Link Panel (16 panels per full vial); additive
chances reset when a panel is taken. In-game retest of the first-update
behavior and Binder visual/click flow is pending.
The local `1.0.1.14-alpha.1` follow-up compares the current 26.3 Binder against
the new dev-image sequence. The Binder no longer paints five permanently empty
pages: the first strip page appears when a Link Panel is inserted or carried,
and the missing-panel cue remains above it. Clicking that page inserts/removes
the Link Panel using the same server-owned cursor flow as 1.21.1. Its tooltip
also shows the stored panel. Multi-page symbol writing and strip scrolling
cannot be exercised yet because symbol pages are still unported to 26.3.
The local `1.0.1.15-alpha.1` Binder polish uses the Link Panel's actual 16×16
item icon for the missing-panel cue instead of a full-size page sprite. The
red pulse and tooltip remain confined to that icon.
The local `1.0.1.16-alpha.1` slice adds the bundled 1.21.1 symbol-page
identities to the 26.3 creative menu, grouped by category. Their names and
descriptions are reused from the 1.21.1 translations, and the Page item now
persists each symbol ID. These pages currently use the generic symbol-page
artwork; the Legacy-style runtime glyph compositor has not been ported.
They cannot yet be inserted into the Binder or change Age generation. This is
the page-data foundation for the next authored-writing slice.
The local `1.0.1.17-alpha.1` follow-up derives biome pages from the active
26.3 biome registry instead of only the older bundled list. It excludes the
TerraBlender staging placeholder and separates cave/Nether biomes. Full-block
materials and true source-fluid blocks also receive registry-derived pages,
covering eligible new and modded blocks. Five 26.3 feature symbols cover
poplar trees, fallen poplars, shelf mushrooms, red shrubs, and abandoned
camps. These feature pages are identities only until their Age-generation
handlers are ported. Biome, material, and feature pages still use generic
artwork and cannot yet be placed in the Binder.
The local `1.0.1.18-alpha.1` follow-up restores the 1.21.1 animated redaction
for Corrupted Page names and descriptions. It does not yet port the dynamic
glyph artwork used for other symbol pages.
The local `1.0.1.19-alpha.1` slice ports the runtime glyph compositor for
symbol Page items. It uses the shared Legacy component sheet and page
parchment with a bounded 256-texture client cache; generated textures are
released on resource reload. Registry-derived biome and material pages use
the same deterministic four-word poem pattern as 1.21.1. No per-symbol PNGs
are generated in the source tree. Binder insertion and authored Age generation
are still unported.
The local `1.0.1.20-alpha.1` visual follow-up enlarges symbol artwork on Page
items, slightly thickens the component strokes, and centers number glyphs at a
larger size so they remain readable in inventory and creative-menu icons.
The local `1.0.1.21-alpha.1` slice ports the Binder's ordered, scrollable
symbol-page strip. Pages persist in its block inventory and can be inserted,
removed, and moved via the cursor or shift-click. A Binder with symbol pages
cannot yet produce a book: authored Age compilation is the next port step, and
silently producing a random Age would consume the player's writing incorrectly.
The local `1.0.1.22-alpha.1` visual follow-up gives large Binder pages the
transparent, full-size Legacy glyph composition. Item icons retain their
separate parchment-backed, reduced-size artwork.
The local `1.0.1.23-alpha.1` follow-up places Binder glyph overlays in a later
26.3 GUI stratum than the page atlas, preventing texture batching from drawing
the opaque page backgrounds over the dynamic artwork.
The local `1.0.1.24-alpha.1` follow-up renders Binder page glyphs through
their working special item model, scaled to the page thumbnail. Direct GUI
blitting of the generated texture remained blank in the 26.3 client.
The local `1.0.1.25-alpha.1` follow-up marks temporary Binder preview stacks
for glyph-only special-model rendering. The full-size transparent artwork now
uses the working item pipeline without drawing a second, smaller page outline.
The local `1.0.1.26-alpha.1` corrects the temporary preview component's codec
registration, which NeoForge 26.3 requires during bootstrap.
The local `1.0.1.27-alpha.1` slice applies the first authored Age subset:
Standard terrain plus at most one Biome page. The selected biome is persisted
with the Age and used by a fixed-biome Overworld noise generator on creation
and reload. Ordered page IDs are stored on the finished book. Other page
combinations remain in the Binder and cannot yet be assembled, preventing
silent loss of unsupported writing. Multi-biome distribution, other terrain
profiles, and feature/material pages still need their 26.3 generation ports.
The `1.0.1.28`–`1.0.1.32` slices add Tiled, Grid, Native, and five scaled
biome distributions; multiple authored Biome pages; Amplified and Island
terrain; and saved terrain/biome plans across reload. A Binder or Ink Mixer
beside another block no longer hides that block's faces. Unsupported authoring
still stays in the Binder rather than silently producing an incorrect Age.
The local `1.0.1.33-beta.1` slice adds Flat and Void terrain to Binder
authoring. Flat uses vanilla's superflat layers; Void has no generated floor
and uses the selected Biome for its environmental rules. Each accepts zero or
one authored Biome page. Unsupported multi-biome Flat/Void books remain in the
Binder until their custom generator is ported. The operator probe can create
test Ages with `rivencraft_port age create <name> <seed> flat|void`.
The local `1.0.1.34-beta.1` follow-up starts Flat's solid layers at Y=0
(bedrock at 0, dirt at 1–2, grass at 3) and lands players at the ground surface
instead of creating an elevated fallback platform. This affects newly
generated Flat chunks; previously generated Age chunks are not rewritten.
The local `1.0.1.35-beta.1` follow-up enables biome decoration on newly
generated Flat chunks, allowing the selected biome's trees, vegetation, and
other placed features to appear on the Flat surface. Void remains undecorated.
The local `1.0.1.36-beta.1` slice accepts multiple Biome pages with Flat and
Void terrain. It ports the 1.21.1 authored-biome Flat generator to 26.3, so
Tiled, Grid, and scaled regions affect climate in both terrain types and
surface decoration in Flat. Void continues to generate no floor or biome
decoration. The operator-only `age create_multi` probe creates two-biome test
Ages for the dedicated-server smoke test.
The local `1.0.1.37-beta.1` slice adds the Cave terrain page to Binder
authoring. It uses Minecraft's caves noise settings, accepts Cave and Nether
biome pages (including multi-biome distribution), and searches below Y=64 for
a safe arrival instead of landing on the roof. The 26.3 vanilla Cave preset
defaults to water below Y=32, so this port derives a dry Cave setting with
air as its default fluid and the sea level below the world floor. This applies
to newly generated Cave chunks; it does not drain existing chunks. Cave-specific Nether surface
materials from 1.21.1 are not ported yet. The dedicated-server smoke now
creates and reloads Cave Ages alongside the other terrain families.
The dry-Cave follow-up is packaged as `1.0.1.38-beta.1`; no 26.3 release is
published until the in-game check passes and publication is explicitly requested.
The local `1.0.1.39-beta.1` follow-up rejects fluid at both feet and head
height during safe-landing searches. It also refuses to carve the fallback
platform into submerged space if no dry footing can be found.
From the repository root:
@@ -44,7 +208,7 @@ Minecraft 1.21.1/Java 21 build:
./gradlew build
```
The alpha JAR is under `ports/mc263/build/libs`. Continue the port and its
The beta JAR is under `ports/mc263/build/libs`. Continue the port and its
full dual-version validation in issues #98–#104 before calling 26.3 stable.
The dedicated development server uses `build/run-server`, with its own world
@@ -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.
+81
View File
@@ -59,12 +59,77 @@ def generateModMetadata = tasks.register('generateModMetadata', ProcessResources
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
tasks.named('processResources', ProcessResources).configure {
// Reuse the existing sprite and base model without copying binary assets.
from('../../src/main/resources') {
include 'assets/rivencraft/textures/item/crystal_shard.png'
include 'assets/rivencraft/textures/item/page.png'
include 'assets/rivencraft/textures/item/page_link_panel.png'
include 'assets/rivencraft/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/ink_vial.png.mcmeta'
@@ -79,6 +144,22 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/textures/gui/notebook_pages.png'
include 'assets/rivencraft/textures/gui/bookui_pager.png'
include 'assets/rivencraft/textures/block/crystal_block.png'
include 'assets/rivencraft/textures/block/book_binder.png'
include 'assets/rivencraft/textures/block/book_binder_press.png'
include 'assets/rivencraft/textures/gui/book_binder_legacy.png'
include 'assets/rivencraft/textures/gui/ink_mixer_legacy.png'
include 'assets/rivencraft/textures/block/inkwell_base.png'
include 'assets/rivencraft/textures/block/inkwell_cup.png'
include 'assets/rivencraft/textures/block/ink.png'
include 'assets/rivencraft/textures/block/ink_glass.png'
include 'assets/rivencraft/models/block/ink_mixer.json'
include 'assets/rivencraft/models/item/ink_mixer.json'
include 'assets/rivencraft/blockstates/ink_mixer.json'
include 'data/rivencraft/loot_table/blocks/ink_mixer.json'
include 'assets/rivencraft/models/block/book_binder.json'
include 'assets/rivencraft/models/item/book_binder.json'
include 'assets/rivencraft/blockstates/book_binder.json'
include 'data/rivencraft/loot_table/blocks/book_binder.json'
include 'assets/rivencraft/models/block/crystal_block.json'
include 'assets/rivencraft/blockstates/crystal_block.json'
include 'assets/rivencraft/blockstates/budding_crystal.json'
+1 -1
View File
@@ -7,4 +7,4 @@ neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.9-alpha.1
mod_version=1.0.1.39-beta.1
+45
View File
@@ -0,0 +1,45 @@
{
"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.port_status": "Binder currently supports Standard terrain and one Biome page; other effects are still being ported.",
"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": "Base for future symbol writing; not yet usable in this port.",
"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": "Writing not yet ported",
"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": "Poplar tree generation symbol.",
"symbol.rivencraft.feature.fallen_poplar_trees": "Fallen Poplar Trees",
"symbol.rivencraft.feature.fallen_poplar_trees.description": "Fallen poplar tree generation symbol.",
"symbol.rivencraft.feature.shelf_mushrooms": "Shelf Mushrooms",
"symbol.rivencraft.feature.shelf_mushrooms.description": "Shelf mushroom generation symbol.",
"symbol.rivencraft.feature.red_shrubs": "Red Shrubs",
"symbol.rivencraft.feature.red_shrubs.description": "Red shrub generation symbol.",
"symbol.rivencraft.feature.abandoned_camps": "Abandoned Camps",
"symbol.rivencraft.feature.abandoned_camps.description": "Abandoned camp structure generation symbol."
}
@@ -6,13 +6,22 @@ import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.dnorberg.rivencraft.age.PortAgeCommands;
import net.dnorberg.rivencraft.age.AgeCatalogSync;
import net.dnorberg.rivencraft.block.BuddingCrystalBlock;
import net.dnorberg.rivencraft.block.PortBookBinderBlock;
import net.dnorberg.rivencraft.block.InkMixerBlock;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.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;
@@ -26,11 +35,14 @@ 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;
@@ -42,6 +54,10 @@ public final class Rivencraft {
public static final Logger LOGGER = LogUtils.getLogger();
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID);
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
private static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITY_TYPES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, MOD_ID);
private static final DeferredRegister<MenuType<?>> MENUS =
DeferredRegister.create(Registries.MENU, MOD_ID);
private static final DeferredRegister<RecipeSerializer<?>> RECIPE_SERIALIZERS =
DeferredRegister.create(Registries.RECIPE_SERIALIZER, MOD_ID);
private static final DeferredRegister.DataComponents COMPONENTS =
@@ -57,12 +73,45 @@ public final class Rivencraft {
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<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 DeferredItem<BlockItem> CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK);
public static final DeferredBlock<BuddingCrystalBlock> BUDDING_CRYSTAL = BLOCKS.registerBlock(
"budding_crystal", BuddingCrystalBlock::new,
@@ -98,9 +147,13 @@ public final class Rivencraft {
public Rivencraft(IEventBus modEventBus) {
BLOCKS.register(modEventBus);
ITEMS.register(modEventBus);
BLOCK_ENTITY_TYPES.register(modEventBus);
MENUS.register(modEventBus);
COMPONENTS.register(modEventBus);
RECIPE_SERIALIZERS.register(modEventBus);
ModCreativeTabs.register(modEventBus);
PortBiomeSources.register(modEventBus);
PortChunkGenerators.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
@@ -17,7 +17,14 @@ 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))
.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)
).apply(instance, AgeLevelIndex::new));
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
@@ -28,15 +35,27 @@ final class AgeLevelIndex extends SavedData {
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 AgeLevelIndex() {
this.ages = new LinkedHashMap<>();
this.retired = new LinkedHashSet<>();
this.biomeOverrides = new LinkedHashMap<>();
this.biomePlans = new LinkedHashMap<>();
this.terrain = new LinkedHashMap<>();
}
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired) {
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired,
Map<Identifier, Identifier> biomeOverrides,
Map<Identifier, PortBiomePlan> biomePlans,
Map<Identifier, PortAgeTerrain> terrain) {
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);
}
Map<Identifier, Long> entries() {
@@ -51,23 +70,42 @@ final class AgeLevelIndex extends SavedData {
return ages.get(id);
}
void add(Identifier id, long seed) {
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);
}
void add(Identifier id, long seed, PortBiomePlan biomePlan, PortAgeTerrain ageTerrain) {
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);
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);
retired.add(id);
setDirty();
}
void restoreAfterFailedRetirement(Identifier id, long seed) {
void restoreAfterFailedRetirement(Identifier id, long seed, PortBiomePlan biomePlan,
PortAgeTerrain ageTerrain) {
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);
setDirty();
}
@@ -3,9 +3,11 @@ package net.dnorberg.rivencraft.age;
import com.mojang.brigadier.arguments.LongArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.minecraft.commands.Commands;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.registries.Registries;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
@@ -20,6 +22,7 @@ import net.neoforged.neoforge.event.RegisterCommandsEvent;
import net.neoforged.fml.ModList;
import java.util.Map;
import java.util.TreeMap;
import java.util.Locale;
/** Operator-only backend and synchronized-travel probes, not normal book behavior. */
public final class PortAgeCommands {
@@ -66,23 +69,49 @@ public final class PortAgeCommands {
.then(Commands.literal("create")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
String name = StringArgumentType.getString(context, "name");
Identifier id = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "age/" + name);
long seed = LongArgumentType.getLong(context, "seed");
ServerLevel level;
try {
level = RuntimeAgeBackend.create(
context.getSource().getServer(), id, seed);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Created " + id + " with seed " + level.getSeed()), true);
return 1;
}))))
.executes(context -> createAge(context, PortAgeTerrain.STANDARD))
.then(Commands.argument("terrain", StringArgumentType.word())
.executes(context -> {
PortAgeTerrain terrain;
try {
terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(
context, "terrain").toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
context.getSource().sendFailure(Component.literal(
"Unknown terrain; use standard, amplified, island, cave, flat, or void"));
return 0;
}
return createAge(context, terrain);
})))))
.then(Commands.literal("create_multi")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("terrain", StringArgumentType.word())
.executes(context -> {
PortAgeTerrain terrain;
try {
terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(
context, "terrain").toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
context.getSource().sendFailure(Component.literal(
"Unknown terrain; use cave, flat, or void"));
return 0;
}
if (terrain != PortAgeTerrain.CAVE
&& terrain != PortAgeTerrain.FLAT
&& terrain != PortAgeTerrain.VOID) {
context.getSource().sendFailure(Component.literal(
"Multi-biome probe supports cave, flat, or void only"));
return 0;
}
return createAge(context, terrain, new PortBiomePlan(
PortBiomePlan.Distribution.TILED,
terrain == PortAgeTerrain.CAVE
? java.util.List.of(Identifier.withDefaultNamespace("lush_caves"),
Identifier.withDefaultNamespace("dripstone_caves"))
: java.util.List.of(Identifier.withDefaultNamespace("plains"),
Identifier.withDefaultNamespace("forest"))));
})))))
.then(Commands.literal("list")
.executes(context -> {
var ages = RuntimeAgeBackend.savedAges(context.getSource().getServer());
@@ -185,7 +214,33 @@ public final class PortAgeCommands {
.executes(context -> sampleAge(context, 0))
.then(Commands.argument("chunkOffset", IntegerArgumentType.integer(0))
.executes(context -> sampleAge(context,
IntegerArgumentType.getInteger(context, "chunkOffset"))))))));
IntegerArgumentType.getInteger(context, "chunkOffset"))))))
.then(Commands.literal("biome")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("x", IntegerArgumentType.integer())
.then(Commands.argument("z", IntegerArgumentType.integer())
.executes(PortAgeCommands::sampleBiome)))))));
}
private static int createAge(CommandContext<CommandSourceStack> context, PortAgeTerrain terrain) {
return createAge(context, terrain, null);
}
private static int createAge(CommandContext<CommandSourceStack> context,
PortAgeTerrain terrain, PortBiomePlan biomePlan) {
String name = StringArgumentType.getString(context, "name");
Identifier id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
long seed = LongArgumentType.getLong(context, "seed");
ServerLevel level;
try {
level = RuntimeAgeBackend.create(context.getSource().getServer(), id, seed, biomePlan, terrain);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Created " + id + " with seed " + level.getSeed() + " terrain=" + terrain), true);
return 1;
}
private static int sampleAge(com.mojang.brigadier.context.CommandContext<net.minecraft.commands.CommandSourceStack> context,
@@ -213,6 +268,23 @@ public final class PortAgeCommands {
return 1;
}
private static int sampleBiome(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
int x = IntegerArgumentType.getInteger(context, "x");
int z = IntegerArgumentType.getInteger(context, "z");
level.getChunk(x >> 4, z >> 4);
Identifier biome = level.getBiome(new net.minecraft.core.BlockPos(x, 4, z))
.unwrapKey().orElseThrow().identifier();
context.getSource().sendSuccess(() -> Component.literal(
id + " biome at " + x + "," + z + "=" + biome), false);
return 1;
}
private static Identifier ageId(com.mojang.brigadier.context.CommandContext<net.minecraft.commands.CommandSourceStack> context) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"age/" + StringArgumentType.getString(context, "name"));
@@ -0,0 +1,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
}
@@ -11,6 +11,8 @@ 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;
import net.minecraft.world.level.levelgen.FlatLevelSource;
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
/** Shared, server-authoritative landing for the 26.3 book and operator probe. */
public final class PortAgeTravel {
@@ -31,16 +33,47 @@ public final class PortAgeTravel {
}
private static BlockPos findLanding(ServerLevel target) {
if (target.getChunkSource().getGenerator() 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())) {
// 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));
}
// A Flat Age has a known surface. Use it directly before a heightmap
// fallback can place a platform in the air above a low superflat world.
FlatLevelGeneratorSettings flatSettings = switch (target.getChunkSource().getGenerator()) {
case FlatLevelSource flat -> flat.settings();
case PortFlatChunkGenerator flat -> flat.settings();
default -> null;
};
if (flatSettings != null && !flatSettings.getLayersInfo().isEmpty()) {
int surfaceY = target.getMinY() + flatSettings.getLayersInfo().stream()
.mapToInt(layer -> layer.getHeight()).sum();
BlockPos surface = new BlockPos(0, surfaceY, 0);
if (hasFooting(target, surface)) return surface;
}
for (int radius = 0; radius <= 16; radius++) {
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
int y = target.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z);
BlockPos foot = new BlockPos(x, y, z);
BlockPos floor = foot.below();
if (target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP)
&& target.getBlockState(foot).getCollisionShape(target, foot).isEmpty()
&& target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty()) {
if (hasFooting(target, foot)) {
return foot;
}
}
@@ -50,14 +83,34 @@ public final class PortAgeTravel {
// origin is ocean or foliage. Final book landing rules are ported later.
int y = Math.max(64, target.getHeight(Heightmap.Types.MOTION_BLOCKING, 0, 0) + 2);
if (y + 2 >= target.getMaxY()) return null;
BlockPos foot = new BlockPos(0, y, 0);
return makePlatform(target, new BlockPos(0, y, 0));
}
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(new BlockPos(x, y - 1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
target.setBlock(foot.offset(x, -1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
}
}
target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3);
target.setBlock(foot.above(), Blocks.AIR.defaultBlockState(), 3);
return foot;
}
private static boolean hasFooting(ServerLevel target, BlockPos foot) {
BlockPos floor = foot.below();
return target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP)
&& target.getFluidState(foot).isEmpty()
&& target.getFluidState(foot.above()).isEmpty()
&& target.getBlockState(foot).getCollisionShape(target, foot).isEmpty()
&& target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty();
}
}
@@ -0,0 +1,25 @@
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 authored biome choice; repeated entries retain their selection 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 == Distribution.NATIVE && !biomes.isEmpty()
|| 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,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,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;
}
}
@@ -8,9 +8,13 @@ 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;
@@ -18,7 +22,18 @@ 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.DerivedLevelData;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.common.NeoForge;
@@ -41,7 +56,8 @@ public final class RuntimeAgeBackend {
AgeLevelIndex index = index(server);
for (Map.Entry<Identifier, Long> age : index.entries().entrySet()) {
try {
load(server, age.getKey(), age.getValue());
load(server, age.getKey(), age.getValue(), index.biomePlan(age.getKey()),
index.terrain(age.getKey()));
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not restore Age {}. Its saved index remains intact.", age.getKey(), exception);
}
@@ -50,12 +66,21 @@ public final class RuntimeAgeBackend {
/** 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) {
checkServerThread(server);
ageName(id);
AgeLevelIndex index = index(server);
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
ServerLevel level = load(server, id, seed);
index.add(id, seed);
ServerLevel level = load(server, id, seed, biomePlan, terrain);
index.add(id, seed, biomePlan, terrain);
// 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.
@@ -75,7 +100,7 @@ public final class RuntimeAgeBackend {
public static ServerLevel reload(MinecraftServer server, Identifier id) {
checkServerThread(server);
long seed = requireSeed(server, id);
ServerLevel level = load(server, id, seed);
ServerLevel level = load(server, id, seed, index(server).biomePlan(id), index(server).terrain(id));
AgeCatalogSync.onCatalogChanged(server);
return level;
}
@@ -85,18 +110,20 @@ public final class RuntimeAgeBackend {
checkServerThread(server);
AgeLevelIndex index = index(server);
long previousSeed = requireSeed(server, id);
PortBiomePlan biomePlan = index.biomePlan(id);
PortAgeTerrain terrain = index.terrain(id);
if (previousSeed == seed) return;
boolean loaded = server.getLevel(dimensionKey(id)) != null;
if (loaded) unloadLoaded(server, id);
try {
if (loaded) load(server, id, seed);
if (loaded) load(server, id, seed, biomePlan, terrain);
index.reseed(id, seed);
server.overworld().getDataStorage().saveAndJoin();
if (loaded) AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException exception) {
if (loaded) {
try {
load(server, id, previousSeed);
load(server, id, previousSeed, biomePlan, terrain);
AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
@@ -112,6 +139,8 @@ public final class RuntimeAgeBackend {
String name = ageName(id);
AgeLevelIndex index = index(server);
long seed = requireSeed(server, id);
PortBiomePlan biomePlan = index.biomePlan(id);
PortAgeTerrain terrain = index.terrain(id);
boolean loaded = server.getLevel(dimensionKey(id)) != null;
ArchivePaths paths = archivePaths(server, name);
if (loaded) unloadLoaded(server, id);
@@ -130,14 +159,14 @@ public final class RuntimeAgeBackend {
}
} catch (IOException | RuntimeException exception) {
try {
index.restoreAfterFailedRetirement(id, seed);
index.restoreAfterFailedRetirement(id, seed, biomePlan, terrain);
server.overworld().getDataStorage().saveAndJoin();
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
if (loaded) {
try {
load(server, id, seed);
load(server, id, seed, biomePlan, terrain);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
@@ -233,17 +262,98 @@ public final class RuntimeAgeBackend {
private record ArchivePaths(Path live, Path archive) {
}
private static ServerLevel load(MinecraftServer server, Identifier id, long seed) {
private static BiomeSource authoredBiomeSource(MinecraftServer server, PortBiomePlan plan, long seed) {
if (plan.distribution() == PortBiomePlan.Distribution.NATIVE)
throw new IllegalArgumentException("Native biome distribution has no authored source");
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 -> throw new IllegalStateException("Native is not an override");
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) {
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
// This prototype uses vanilla Overworld terrain until issue #98 ports
// authored ChunkGenerators. LevelStem.seedOverride controls chunk random
// state as well as ServerLevel.getSeed() in NeoForge 26.3.
// Noise terrains reuse the Overworld biome source unless a
// non-Native authored distribution 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");
LevelStem stem = new LevelStem(overworldStem.type(), overworldStem.generator(), OptionalLong.of(seed));
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) {
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, settings);
}
LevelStem stem = new LevelStem(overworldStem.type(), generator, OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
ServerLevel level = new ServerLevel(server, server.executor, server.storageSource,
new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()),
@@ -273,4 +383,14 @@ public final class RuntimeAgeBackend {
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,47 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
/** 26.3 API adaptation of the 1.21.1 Ink Mixer block. */
public final class InkMixerBlock extends BaseEntityBlock {
public InkMixerBlock(Properties properties) { super(properties); }
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new InkMixerBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof InkMixerBlockEntity mixer) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new InkMixerMenu(id, inventory, mixer),
Component.translatable("container.rivencraft.ink_mixer")),
buffer -> buffer.writeBlockPos(pos));
}
return InteractionResult.SUCCESS;
}
@Override public void playerDestroy(ServerLevel level, ServerPlayer player, BlockPos pos,
BlockState state, BlockEntity entity, ItemStack tool) {
if (entity instanceof InkMixerBlockEntity mixer) Containers.dropContents(level, pos, mixer);
super.playerDestroy(level, player, pos, state, entity, tool);
}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.phys.BlockHitResult;
public final class PortBookBinderBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public PortBookBinderBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING);
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new PortBookBinderBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof PortBookBinderBlockEntity binder) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new PortBookBinderMenu(id, inventory, binder),
Component.translatable("container.rivencraft.book_binder")),
buffer -> {
buffer.writeBlockPos(pos);
buffer.writeUtf(binder.title(), 32);
});
}
return InteractionResult.SUCCESS;
}
@Override public void playerDestroy(ServerLevel level, ServerPlayer player, BlockPos pos,
BlockState state, BlockEntity entity, ItemStack tool) {
if (entity instanceof PortBookBinderBlockEntity binder) Containers.dropContents(level, pos, binder);
super.playerDestroy(level, player, pos, state, entity, tool);
}
}
@@ -0,0 +1,176 @@
package net.dnorberg.rivencraft.block.entity;
import java.util.Random;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** Server-owned Link Panel mixer with a persistent, Legacy-style additive mixture. */
public final class InkMixerBlockEntity extends BlockEntity implements Container {
public static final int INK = 0;
public static final int PAPER = 1;
public static final int BOTTLES = 2;
public static final int SIZE = 3;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int inkRemaining;
private long nextModifierSeed = new Random().nextLong();
private final double[] modifierChances = new double[3];
private static final int[] FLAGS = {LinkProperties.DISARM,
LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.INTRA_LINKING};
private static final String[] CHANCE_KEYS = {"DisarmChance", "MomentumChance", "IntraLinkingChance"};
private static final int[] OLD_CHANCE_INDEX = {0, 2, 3};
public InkMixerBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.INK_MIXER_BLOCK_ENTITY.get(), pos, state);
}
public int inkRemaining() { return inkRemaining; }
public int modifierChancePermille(int index) {
return Math.clamp((int) Math.round(modifierChances[index] * 1000), 0, 1000);
}
/** Infuses a carried ingredient into the current ink pool; only the server calls this. */
public boolean addModifier(ItemStack carried, int amount) {
InkModifierIngredients.Ingredient ingredient = InkModifierIngredients.find(carried);
if (ingredient == null || inkRemaining == 0 || amount <= 0 || carried.getCount() < amount)
return false;
for (InkModifierIngredients.Effect effect : ingredient.effects())
for (int index = 0; index < FLAGS.length; index++)
if (FLAGS[index] == effect.flag())
modifierChances[index] = 1.0 - (1.0 - modifierChances[index])
* Math.pow(1.0 - effect.chance(), amount);
carried.shrink(amount);
setChanged();
return true;
}
public ItemStack previewLinkPanel() {
if (!canCreateLinkPanel()) return ItemStack.EMPTY;
ItemStack panel = PageItem.createLinkPanelPage();
LinkProperties properties = rollProperties(nextModifierSeed).properties();
if (properties.flags() != 0) panel.set(Rivencraft.LINK_PROPERTIES.get(), properties);
return panel;
}
private record Roll(LinkProperties properties, long nextSeed) {}
private Roll rollProperties(long seed) {
Random random = new Random(seed);
int selected = 0;
for (int index = 0; index < FLAGS.length; index++)
if (modifierChances[index] > 0 && random.nextDouble() < modifierChances[index])
selected |= FLAGS[index];
return new Roll(new LinkProperties(selected), random.nextLong());
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) {
setChanged();
if (slot == BOTTLES) refillEmptyBasin();
}
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
setChanged();
if (slot == INK || slot == BOTTLES) refillEmptyBasin();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return switch (slot) {
case INK -> stack.is(Rivencraft.INK_VIAL.get());
case PAPER -> stack.is(Items.PAPER);
default -> false;
};
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
/** No server tick: refill only when ink, bottle space, or consumption changes. */
private void refillEmptyBasin() {
if (level != null && !level.isClientSide() && inkRemaining == 0) {
int addedInk = InkVialExchange.exchange(items, INK, BOTTLES);
if (addedInk > 0) {
inkRemaining = addedInk;
setChanged();
}
}
}
public boolean canCreateLinkPanel() {
if (!items.get(PAPER).is(Items.PAPER)) return false;
return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, 1);
}
/** Commit inputs only when a virtual output is actually taken. */
public boolean consumeLinkPanelMaterials() {
if (!canCreateLinkPanel()) return false;
int cost = 1;
if (inkRemaining < cost) {
inkRemaining += InkVialExchange.exchange(items, INK, BOTTLES);
}
inkRemaining -= cost;
items.get(PAPER).shrink(1);
if (items.get(PAPER).isEmpty()) items.set(PAPER, ItemStack.EMPTY);
nextModifierSeed = rollProperties(nextModifierSeed).nextSeed();
java.util.Arrays.fill(modifierChances, 0);
setChanged();
if (inkRemaining == 0) refillEmptyBasin();
return true;
}
@Override public void setLevel(Level level) {
super.setLevel(level);
refillEmptyBasin();
}
@Override public void clearContent() {
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
java.util.Arrays.fill(modifierChances, 0);
setChanged();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
inkRemaining = Math.clamp(input.getIntOr("InkRemaining", 0), 0, InkVialItem.CAPACITY);
nextModifierSeed = input.getLongOr("NextModifierSeed", nextModifierSeed);
for (int index = 0; index < modifierChances.length; index++) {
modifierChances[index] = Math.clamp(input.getDoubleOr(CHANCE_KEYS[index],
input.getDoubleOr("ModifierChance" + OLD_CHANCE_INDEX[index], 0.0)), 0.0, 1.0);
}
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
output.putInt("InkRemaining", inkRemaining);
output.putLong("NextModifierSeed", nextModifierSeed);
for (int index = 0; index < modifierChances.length; index++)
output.putDouble(CHANCE_KEYS[index], modifierChances[index]);
}
}
@@ -0,0 +1,141 @@
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;
}
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,167 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
/** 1.21.1 Legacy atlas and mixer animation adapted to the 26.3 GUI API. */
public final class InkMixerScreen extends AbstractContainerScreen<InkMixerMenu> {
private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/ink_mixer_legacy.png");
private static final int BASIN_X = 88;
private static final int BASIN_Y = 49;
private static final int BASIN_RADIUS = 30;
private static final int[] PROPERTY_COLORS = {0xFFE84848, 0xFF5555FF, 0xFF55EF55};
private final int[] lastChances = new int[3];
private boolean basinClickPendingRelease;
private long lastAdditionTime;
public InkMixerScreen(InkMixerMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 181);
inventoryLabelY = 87;
}
@Override public boolean isPauseScreen() { return false; }
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractBackground(graphics, mouseX, mouseY, partialTick);
int left = (width - imageWidth) / 2;
int top = (height - imageHeight) / 2;
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, left + 54, top + 16,
179, 16, 66, 65, 256, 256);
if (menu.inkRemaining() > 0) {
graphics.fill(left + 54, top + 16, left + 120, top + 81, 0xFF101014);
renderPropertyGlyph(graphics);
}
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, left, top,
0, 0, imageWidth, imageHeight, 256, 256);
}
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
}
@Override protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
super.extractTooltip(graphics, mouseX, mouseY);
if (overBasin(mouseX, mouseY)) {
var lines = new java.util.ArrayList<Component>();
lines.add(Component.translatable("screen.rivencraft.ink_mixer.ink",
menu.inkRemaining(), InkVialItem.CAPACITY));
lines.add(Component.translatable("screen.rivencraft.ink_mixer.additive_hint"));
for (int index = 0; index < 3; index++) {
int chance = menu.modifierChancePermille(index);
if (chance > 0) lines.add(Component.translatable("screen.rivencraft.ink_mixer.chance",
switch (index) {
case 0 -> Component.translatable("item.rivencraft.link_property.disarm");
case 1 -> Component.translatable("item.rivencraft.link_property.maintain_momentum");
default -> Component.translatable("item.rivencraft.link_property.intra_linking");
}, chance / 10.0));
}
graphics.setComponentTooltipForNextFrame(font, lines, mouseX, mouseY, ItemStack.EMPTY, null);
}
}
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if ((event.button() == InputConstants.MOUSE_BUTTON_LEFT
|| event.button() == InputConstants.MOUSE_BUTTON_RIGHT)
&& overBasin(event.x(), event.y()) && !menu.getCarried().isEmpty()) {
minecraft.gameMode.handleContainerInput(menu.containerId, InkMixerMenu.MODIFIER,
event.button() == InputConstants.MOUSE_BUTTON_RIGHT ? 1 : 0,
ContainerInput.PICKUP, minecraft.player);
basinClickPendingRelease = true;
return true;
}
return super.mouseClicked(event, doubleClick);
}
@Override public boolean mouseReleased(MouseButtonEvent event) {
if (basinClickPendingRelease) {
basinClickPendingRelease = false;
return true;
}
return super.mouseReleased(event);
}
private boolean overBasin(double mouseX, double mouseY) {
double x = mouseX - leftPos - BASIN_X;
double y = mouseY - topPos - BASIN_Y;
return x * x + y * y < BASIN_RADIUS * BASIN_RADIUS;
}
/** A GUI-scale projection of Legacy's colored, rotating D'ni ink effect. */
private void renderPropertyGlyph(GuiGraphicsExtractor graphics) {
long now = System.currentTimeMillis();
int[] active = new int[3];
int count = 0;
int strongest = 0;
for (int index = 0; index < lastChances.length; index++) {
int chance = menu.modifierChancePermille(index);
if (chance > lastChances[index]) lastAdditionTime = now;
lastChances[index] = chance;
if (chance > 0) {
active[count++] = index;
strongest = Math.max(strongest, chance);
}
}
if (count == 0) {
lastAdditionTime = 0;
return;
}
double cycle = now / 1800.0;
int first = active[(int) cycle % count];
int second = active[((int) cycle + 1) % count];
double blend = (1.0 - Math.cos((cycle % 1.0) * Math.PI)) / 2.0;
int rgb = blendRgb(PROPERTY_COLORS[first], PROPERTY_COLORS[second], blend);
double pulse = Math.max(0.0, 1.0 - (now - lastAdditionTime) / 700.0);
int alpha = Math.clamp((int) (70 + strongest * 0.14 + pulse * 45), 0, 255);
int color = alpha << 24 | rgb;
int centerX = leftPos + BASIN_X;
int centerY = topPos + BASIN_Y;
double turn = now / 1200.0;
for (int spoke = 0; spoke < 4; spoke++) {
for (int step = 0; step <= 30; step++) {
double progress = step / 30.0;
double angle = turn + spoke * Math.PI / 2.0 + progress * 0.9;
double radius = 3.0 + progress * (15.0 + pulse * 3.0);
plot(graphics, centerX + Math.cos(angle) * radius,
centerY + Math.sin(angle) * radius, color);
}
}
for (int step = 0; step < 96; step++) {
double angle = step * Math.PI / 48.0 + turn * 0.3;
plot(graphics, centerX + Math.cos(angle) * 19.0,
centerY + Math.sin(angle) * 19.0, color);
if (strongest >= 990 && step % 2 == 0) {
plot(graphics, centerX + Math.cos(angle) * 23.0,
centerY + Math.sin(angle) * 23.0, color);
}
}
}
private static int blendRgb(int first, int second, double amount) {
int red = (int) (((first >> 16) & 0xFF) * (1.0 - amount)
+ ((second >> 16) & 0xFF) * amount);
int green = (int) (((first >> 8) & 0xFF) * (1.0 - amount)
+ ((second >> 8) & 0xFF) * amount);
int blue = (int) ((first & 0xFF) * (1.0 - amount) + (second & 0xFF) * amount);
return red << 16 | green << 8 | blue;
}
private static void plot(GuiGraphicsExtractor graphics, double x, double y, int color) {
int pixelX = (int) Math.round(x);
int pixelY = (int) Math.round(y);
graphics.fill(pixelX, pixelY, pixelX + 1, pixelY + 1, color);
}
}
@@ -0,0 +1,34 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.neoforged.neoforge.client.event.RegisterSpecialModelRendererEvent;
import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.resources.ResourceManagerReloadListener;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class PortBookBinderClientEvents {
private PortBookBinderClientEvents() {}
@SubscribeEvent
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::new);
}
@SubscribeEvent
public static void registerPageRenderer(RegisterSpecialModelRendererEvent event) {
event.register(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "symbol_page"),
PortSymbolPageRenderer.Unbaked.MAP_CODEC);
}
@SubscribeEvent
public static void registerPageTextureReload(AddClientReloadListenersEvent event) {
event.addListener(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "symbol_page_textures"),
(ResourceManagerReloadListener) manager -> PortSymbolPageTextures.clear());
}
}
@@ -0,0 +1,257 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
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.network.AssembleBinderPayload;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.network.SetBinderTitlePayload;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
/** Legacy-sized Binder with a page strip and single-click book assembly. */
public final class PortBookBinderScreen extends AbstractContainerScreen<PortBookBinderMenu> {
private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(
"rivencraft", "textures/gui/book_binder_legacy.png");
private static final Identifier PAGE_ATLAS = Identifier.fromNamespaceAndPath(
"rivencraft", "textures/gui/notebook_pages.png");
private static final int PAGE_Y = 48;
private static final int PAGE_WIDTH = 26;
private static final int PAGE_HEIGHT = 34;
private static final int PAGE_STEP = 28;
private static final int VISIBLE_PAGES = 5;
private static final int STRIP_X = 7;
private static final int STRIP_Y = 45;
private static final int STRIP_WIDTH = 162;
private static final int STRIP_HEIGHT = 40;
private static final int MISSING_PANEL_X = 27;
private static final int MISSING_PANEL_Y = 26;
private static final int MISSING_PANEL_SIZE = 16;
private static final ItemStack MISSING_PANEL_ICON = PageItem.createLinkPanelPage();
private EditBox titleInput;
private boolean stripClickPendingRelease;
private int firstVisible;
public PortBookBinderScreen(PortBookBinderMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 181);
inventoryLabelY = 87;
}
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() {
super.init();
int left = (width - imageWidth) / 2;
int top = (height - imageHeight) / 2;
titleInput = new EditBox(font, left + 9, top + 10, 112, 12, Component.literal("Age name"));
titleInput.setBordered(false);
titleInput.setTextColor(0xFFFFFFFF);
titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setValue(menu.title());
titleInput.setHint(Component.literal("Name this Age"));
titleInput.setResponder(value -> ClientPacketDistributor.sendToServer(
new SetBinderTitlePayload(menu.containerId, value)));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
}
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractBackground(graphics, mouseX, mouseY, partialTick);
int left = (width - imageWidth) / 2;
int top = (height - imageHeight) / 2;
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, left, top, 0, 0,
imageWidth, imageHeight, 256, 256);
graphics.fill(left + 7, top + 9, left + 123, top + 23, 0xFF000000);
firstVisible = Math.min(firstVisible, maxFirstVisible());
int visibleCount = Math.min(VISIBLE_PAGES, visiblePageCount() - firstVisible);
for (int visible = 0; visible < visibleCount; visible++) {
int x = left + 16 + visible * PAGE_STEP;
graphics.blit(RenderPipelines.GUI_TEXTURED, PAGE_ATLAS,
x, top + PAGE_Y, 156, 0, PAGE_WIDTH, PAGE_HEIGHT, 256, 256);
}
// GUI extraction batches textures within a stratum. The glyphs need a
// later stratum so another atlas page cannot cover their transparent quad.
graphics.nextStratum();
for (int visible = 0; visible < visibleCount; visible++) {
int index = firstVisible + visible;
int x = left + 16 + visible * PAGE_STEP;
int y = top + PAGE_Y;
ItemStack page = pageAt(index);
if (page.isEmpty()) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xAA000000);
} else if (PageItem.isLinkPanel(page)) {
graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000);
} else {
// Render the full-size transparent glyph through the working
// item pipeline, without its small parchment-backed icon.
ItemStack glyphOnly = page.copy();
glyphOnly.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true);
graphics.pose().pushMatrix();
graphics.pose().translate(x + 1, y + 5);
graphics.pose().scale(1.5F, 1.5F);
graphics.fakeItem(glyphOnly, 0, 0);
graphics.pose().popMatrix();
}
}
graphics.fill(left + STRIP_X, top + STRIP_Y, left + STRIP_X + 8,
top + STRIP_Y + STRIP_HEIGHT, firstVisible == 0 ? 0x33000000 : 0xAA000000);
graphics.fill(left + STRIP_X + STRIP_WIDTH - 8, top + STRIP_Y,
left + STRIP_X + STRIP_WIDTH, top + STRIP_Y + STRIP_HEIGHT,
firstVisible >= maxFirstVisible() ? 0x33000000 : 0xAA000000);
boolean panelPresent = !menu.getSlot(PortBookBinderBlockEntity.PANEL).getItem().isEmpty();
if (titleInput == null || titleInput.getValue().isBlank()) {
graphics.fill(left + 7, top + 9, left + 123, top + 10, 0xFFFF3030);
graphics.fill(left + 7, top + 22, left + 123, top + 23, 0xFFFF3030);
}
if (!panelPresent) {
graphics.fakeItem(MISSING_PANEL_ICON, left + MISSING_PANEL_X, top + MISSING_PANEL_Y);
float phase = (System.currentTimeMillis() % 4000L) / 2000.0F;
float fade = phase > 1.0F ? 2.0F - phase : phase;
int alpha = 40 + (int) (125 * fade);
graphics.fill(left + MISSING_PANEL_X, top + MISSING_PANEL_Y,
left + MISSING_PANEL_X + MISSING_PANEL_SIZE,
top + MISSING_PANEL_Y + MISSING_PANEL_SIZE,
(alpha << 24) | 0x00B02020);
}
if (hasUnsupportedPages()) graphics.text(font,
Component.translatable("screen.rivencraft.book_binder.authored_pending"),
left + 75, top + 31, 0xFFB84B42, false);
if (!hasUnsupportedPages() && menu.getSlot(2).getItem().isEmpty() && !menu.getSlot(0).getItem().isEmpty()
&& !menu.getSlot(1).getItem().isEmpty() && titleInput != null
&& !titleInput.getValue().isBlank()) {
graphics.fakeItem(new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), left + 152, top + 27);
}
}
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
}
@Override protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
super.extractTooltip(graphics, mouseX, mouseY);
int x = mouseX - leftPos;
int y = mouseY - topPos;
if (x >= MISSING_PANEL_X && x < MISSING_PANEL_X + MISSING_PANEL_SIZE
&& y >= MISSING_PANEL_Y && y < MISSING_PANEL_Y + MISSING_PANEL_SIZE
&& menu.getSlot(1).getItem().isEmpty()) {
graphics.setTooltipForNextFrame(font,
Component.translatable("screen.rivencraft.book_binder.missing_panel"), mouseX, mouseY);
} else {
int index = pageIndexAt(mouseX, mouseY);
if (index >= 0 && !pageAt(index).isEmpty())
graphics.setTooltipForNextFrame(font, pageAt(index), mouseX, mouseY);
}
}
private ItemStack pageAt(int index) {
return menu.getSlot(index == 0 ? PortBookBinderBlockEntity.PANEL
: PortBookBinderBlockEntity.FIRST_PAGE + index - 1).getItem();
}
private int displayedPages() {
for (int index = PortBookBinderBlockEntity.PAGE_COUNT; index >= 1; index--)
if (!pageAt(index).isEmpty()) return index + 1;
return pageAt(0).isEmpty() ? 0 : 1;
}
private boolean hasUnsupportedPages() {
java.util.List<Identifier> pages = new java.util.ArrayList<>();
for (int index = 1; index <= PortBookBinderBlockEntity.PAGE_COUNT; index++) {
Identifier id = pageAt(index).get(Rivencraft.SYMBOL_PAGE_ID.get());
if (id != null) pages.add(id);
}
return !PortAuthoredWriting.supports(pages);
}
private int visiblePageCount() {
return Math.min(PortBookBinderBlockEntity.PAGE_COUNT + 1,
displayedPages() + (menu.getCarried().isEmpty() ? 0 : 1));
}
private int maxFirstVisible() { return Math.max(0, visiblePageCount() - VISIBLE_PAGES); }
private int pageIndexAt(double mouseX, double mouseY) {
int x = (int) mouseX - leftPos;
int y = (int) mouseY - topPos;
if (x < 16 || x >= STRIP_X + STRIP_WIDTH - 8 || y < STRIP_Y || y >= STRIP_Y + STRIP_HEIGHT)
return -1;
int visible = (x - 16) / PAGE_STEP;
return visible < VISIBLE_PAGES ? firstVisible + visible : -1;
}
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
int left = (width - imageWidth) / 2;
int top = (height - imageHeight) / 2;
if ((event.button() == InputConstants.MOUSE_BUTTON_LEFT
|| event.button() == InputConstants.MOUSE_BUTTON_RIGHT)
&& event.x() >= left + STRIP_X && event.x() < left + STRIP_X + STRIP_WIDTH
&& event.y() >= top + STRIP_Y && event.y() < top + STRIP_Y + STRIP_HEIGHT) {
stripClickPendingRelease = true;
if (event.x() < left + STRIP_X + 8) firstVisible = Math.max(0, firstVisible - 1);
else if (event.x() >= left + STRIP_X + STRIP_WIDTH - 8)
firstVisible = Math.min(maxFirstVisible(), firstVisible + 1);
else {
int index = pageIndexAt(event.x(), event.y());
if (index >= 0 && index <= displayedPages()
&& (index < displayedPages() || !menu.getCarried().isEmpty()))
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
PortBookBinderMenu.STRIP_CLICK_BASE + index);
}
return true;
}
if (event.button() == InputConstants.MOUSE_BUTTON_LEFT
&& event.x() >= left + 151 && event.x() < left + 170
&& event.y() >= top + 26 && event.y() < top + 45
&& !hasUnsupportedPages() && menu.getSlot(2).getItem().isEmpty()
&& menu.getCarried().isEmpty()) {
String name = titleInput.getValue().strip();
if (!name.isEmpty()) ClientPacketDistributor.sendToServer(new AssembleBinderPayload(name));
return true;
}
return super.mouseClicked(event, doubleClick);
}
@Override public boolean mouseReleased(MouseButtonEvent event) {
if (stripClickPendingRelease) {
stripClickPendingRelease = false;
return true;
}
return super.mouseReleased(event);
}
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + STRIP_WIDTH
&& mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
firstVisible = Math.clamp(firstVisible - (int) Math.signum(scrollY), 0, maxFirstVisible());
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override public boolean keyPressed(KeyEvent event) {
if (titleInput != null && titleInput.isFocused()) {
if (event.isEscape()) {
setFocused(null);
titleInput.setFocused(false);
} else {
titleInput.keyPressed(event);
}
// The character event inserts text. Never forward a focused key
// to the container's inventory/hotbar shortcuts.
return true;
}
return super.keyPressed(event);
}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.serialization.MapCodec;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.special.SpecialModelRenderer;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
import org.joml.Vector3f;
/** Flat item geometry textured with a page composed from its symbol stack. */
public final class PortSymbolPageRenderer implements SpecialModelRenderer<PortSymbolPageRenderer.PageArt> {
@Override
public PageArt extractArgument(ItemStack stack) {
Identifier symbol = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
return symbol == null ? null : new PageArt(symbol,
stack.getOrDefault(Rivencraft.PAGE_GLYPH_ONLY.get(), false));
}
@Override
public void submit(PageArt art, PoseStack pose, SubmitNodeCollector collector,
int light, int overlay, boolean hasFoil, int outlineColor) {
if (art == null) return;
Identifier texture = art.glyphOnly()
? PortSymbolPageTextures.forPage(art.symbol())
: PortSymbolPageTextures.forItem(art.symbol());
collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture),
(matrix, vertices) -> {
vertex(vertices, matrix, 0, 0, 0, 1, light, overlay);
vertex(vertices, matrix, 1, 0, 1, 1, light, overlay);
vertex(vertices, matrix, 1, 1, 1, 0, light, overlay);
vertex(vertices, matrix, 0, 1, 0, 0, light, overlay);
});
}
@Override
public void getExtents(Consumer<org.joml.Vector3fc> extents) {
extents.accept(new Vector3f(0, 0, 0.5F));
extents.accept(new Vector3f(1, 1, 0.5F));
}
private static void vertex(VertexConsumer vertices, PoseStack.Pose pose,
float x, float y, float u, float v, int light, int overlay) {
vertices.addVertex(pose, x, y, 0.5F).setColor(255, 255, 255, 255)
.setUv(u, v).setOverlay(overlay).setLight(light).setNormal(pose, 0, 0, 1);
}
public record PageArt(Identifier symbol, boolean glyphOnly) {}
public record Unbaked() implements SpecialModelRenderer.Unbaked<PageArt> {
public static final Unbaked INSTANCE = new Unbaked();
public static final MapCodec<Unbaked> MAP_CODEC = MapCodec.unit(INSTANCE);
@Override public MapCodec<Unbaked> type() { return MAP_CODEC; }
@Override public SpecialModelRenderer<PageArt> bake(BakingContext context) {
return new PortSymbolPageRenderer();
}
}
}
@@ -0,0 +1,189 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.NativeImage;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortSymbolNumerals;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.util.ARGB;
/** The 1.21.1 Legacy-style glyph compositor adapted to 26.3's texture API. */
public final class PortSymbolPageTextures {
private static final int SIZE = 128;
private static final int GLYPH = 51;
private static final int ITEM_SYMBOL_SIZE = 15;
private static final int NUMBER_GLYPH_SIZE = 64;
private static final int CACHE_LIMIT = 256;
private static final Identifier PAGE = texture("item/page");
private static final Identifier COMPONENTS = texture("gui/symbolcomponents");
private static final Identifier FALLBACK = texture("item/page_symbol");
private static final Map<String, Identifier> CACHE = new LinkedHashMap<>(16, .75F, true);
private static final Map<String, int[]> WORD_CACHE = new LinkedHashMap<>(16, .75F, true);
private static NativeImage page;
private static NativeImage components;
private static int nextId;
// WordData from Mystcraft Legacy 0.13.7.06, matching the 1.21.1 compositor.
private static final Map<String, int[]> WORDS = Map.ofEntries(
Map.entry("Terrain", new int[] {6,10,12,13,16,19,24,25,27}),
Map.entry("Form", new int[] {4,6,8,9,10,11,13,14,16,23}),
Map.entry("Tradition", new int[] {4,6,7,8,9,14,17,18,20}),
Map.entry("Flow", new int[] {4,7,12,13,17,18}),
Map.entry("Constraint", new int[] {5,6,9,11,13,20,22,23}),
Map.entry("Nature", new int[] {5,6,8,10,11,12,15,16,17,22}),
Map.entry("Sustain", new int[] {6,16,19,20,21,23}),
Map.entry("Ethereal", new int[] {4,6,8,13,14,16,18,21,23}),
Map.entry("Dynamic", new int[] {4,5,6,12,13,17,18,22,23}),
Map.entry("Cycle", new int[] {4,5,7,16,17,18}),
Map.entry("Balance", new int[] {4,6,9,10,16,19}),
Map.entry("Transform", new int[] {4,5,6,8,11,14,18,20,21}),
Map.entry("Time", new int[] {4,5,7,12,13,19,20,21,22,23}),
Map.entry("System", new int[] {10,12,14,15,16,20,21,22}),
Map.entry("Void", new int[] {4,5,6,16,17,18,23})
);
private PortSymbolPageTextures() {}
public static Identifier forItem(Identifier id) {
return forSymbol(id, true);
}
/** Transparent, full-size glyphs layered over the Binder's page atlas. */
public static Identifier forPage(Identifier id) {
return forSymbol(id, false);
}
private static Identifier forSymbol(Identifier id, boolean item) {
PortSymbolPages.Entry entry = PortSymbolPages.find(id);
if (entry == null) return FALLBACK;
String poem = entry.poem();
if (!poem.matches("[A-Za-z]+(?: [A-Za-z]+){3}")) poem = "";
String key = id + "|" + poem + (item ? "|item" : "|page");
Identifier cached = CACHE.get(key);
if (cached != null) return cached;
try {
loadSources(Minecraft.getInstance().getResourceManager());
NativeImage image = compose(id, poem, item);
Identifier location = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"generated/page_" + nextId++);
Minecraft.getInstance().getTextureManager().register(location,
new DynamicTexture(() -> "Rivencraft symbol page " + id, image));
CACHE.put(key, location);
if (CACHE.size() > CACHE_LIMIT) {
String eldest = CACHE.keySet().iterator().next();
Minecraft.getInstance().getTextureManager().release(CACHE.remove(eldest));
}
return location;
} catch (IOException exception) {
Rivencraft.LOGGER.error("Could not compose symbol page {}", id, exception);
return FALLBACK;
}
}
public static void clear() {
var textures = Minecraft.getInstance().getTextureManager();
CACHE.values().forEach(textures::release);
CACHE.clear();
WORD_CACHE.clear();
if (page != null) page.close();
if (components != null) components.close();
page = null;
components = null;
}
private static void loadSources(ResourceManager manager) throws IOException {
if (page != null && components != null) return;
try (InputStream pageStream = manager.getResourceOrThrow(PAGE).open();
InputStream componentsStream = manager.getResourceOrThrow(COMPONENTS).open()) {
page = NativeImage.read(pageStream);
components = NativeImage.read(componentsStream);
}
}
private static NativeImage compose(Identifier id, String poem, boolean item) {
NativeImage image = new NativeImage(SIZE, SIZE, true);
if (item) {
for (int y = 0; y < SIZE; y++) for (int x = 0; x < SIZE; x++) {
image.setPixel(x, y, page.getPixel(x * page.getWidth() / SIZE,
y * page.getHeight() / SIZE));
}
}
String numberPath = id.getPath().startsWith("number/")
? id.getPath().substring("number/".length()) : "";
if (id.getNamespace().equals(Rivencraft.MOD_ID)
&& numberPath.matches("(?:[0-9]|1[0-9]|2[0-4])")) {
int digit = Integer.parseInt(numberPath);
int glyphSize = item ? NUMBER_GLYPH_SIZE : GLYPH;
int origin = item ? (SIZE - glyphSize) / 2 : 38;
for (int component : PortSymbolNumerals.componentsForDigit(digit))
blendComponent(image, component, origin, origin, glyphSize);
return image;
}
String[] words = poem.isBlank() ? generatedWords(id) : poem.split(" ");
int[] offsetsX = {38, 77, 38, 0};
int[] offsetsY = {0, 38, 77, 38};
int glyphSize = item ? GLYPH * ITEM_SYMBOL_SIZE / 16 : GLYPH;
for (int word = 0; word < 4; word++) {
int x = item ? scaleForItem(offsetsX[word]) : offsetsX[word];
int y = item ? scaleForItem(offsetsY[word]) : offsetsY[word];
for (int component : glyphs(words[word]))
blendComponent(image, component, x, y, glyphSize);
}
return image;
}
private static void blendComponent(NativeImage image, int component, int x0, int y0, int size) {
int cellX = (component % 8) * 64;
int cellY = (component / 8) * 64;
for (int y = 0; y < size; y++) for (int x = 0; x < size; x++) {
int sourceX = cellX + x * 64 / size;
int sourceY = cellY + y * 64 / size;
// One source-pixel of coverage keeps the thin Legacy strokes legible
// when Minecraft shrinks this 128px composite to a 16px item icon.
int alpha = ARGB.alpha(components.getPixel(sourceX, sourceY));
if (sourceX > cellX) alpha = Math.max(alpha, ARGB.alpha(components.getPixel(sourceX - 1, sourceY)));
if (sourceX < cellX + 63) alpha = Math.max(alpha, ARGB.alpha(components.getPixel(sourceX + 1, sourceY)));
if (sourceY > cellY) alpha = Math.max(alpha, ARGB.alpha(components.getPixel(sourceX, sourceY - 1)));
if (sourceY < cellY + 63) alpha = Math.max(alpha, ARGB.alpha(components.getPixel(sourceX, sourceY + 1)));
if (alpha != 0) image.setPixel(x0 + x, y0 + y,
ARGB.alphaBlend(image.getPixel(x0 + x, y0 + y),
(alpha << 24) | 0x001A212B));
}
}
private static int scaleForItem(int coordinate) {
return SIZE / 2 + Math.round((coordinate - SIZE / 2F) * ITEM_SYMBOL_SIZE / 16F);
}
private static String[] generatedWords(Identifier id) {
String stem = id.toString();
return new String[] {stem + 'a', stem + 'b', stem + 'c', stem + 'd'};
}
private static int[] glyphs(String word) {
int[] known = WORDS.get(word);
if (known != null) return known;
String key = word.toLowerCase(Locale.ROOT);
int[] cached = WORD_CACHE.get(key);
if (cached != null) return cached;
Random random = new Random(key.hashCode());
int[] result = new int[random.nextInt(10) + 3];
for (int i = 0; i < result.length; i++) result[i] = random.nextInt(20) + 4;
WORD_CACHE.put(key, result);
if (WORD_CACHE.size() > CACHE_LIMIT) WORD_CACHE.remove(WORD_CACHE.keySet().iterator().next());
return result;
}
private static Identifier texture(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
}
}
@@ -0,0 +1,29 @@
package net.dnorberg.rivencraft.item;
import com.mojang.serialization.Codec;
import io.netty.buffer.ByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** The three Ink Mixer modifiers currently supported by the 26.3 port. */
public record LinkProperties(int flags) {
public static final int DISARM = 1;
public static final int MAINTAIN_MOMENTUM = 4;
public static final int INTRA_LINKING = 8;
public static final int SUPPORTED = DISARM | MAINTAIN_MOMENTUM | INTRA_LINKING;
public static final LinkProperties NONE = new LinkProperties(0);
public static final Codec<LinkProperties> CODEC = Codec.INT.xmap(LinkProperties::new, LinkProperties::flags);
public static final StreamCodec<ByteBuf, LinkProperties> STREAM_CODEC = StreamCodec.of(
(buffer, value) -> ByteBufCodecs.VAR_INT.encode(buffer, value.flags()),
buffer -> new LinkProperties(ByteBufCodecs.VAR_INT.decode(buffer)));
public LinkProperties {
if (flags < 0 || (flags & ~SUPPORTED) != 0) {
throw new IllegalArgumentException("Unsupported Link Panel flags: " + flags);
}
}
public boolean has(int flag) {
return (flags & flag) != 0;
}
}
@@ -18,6 +18,8 @@ import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
import net.minecraft.world.level.Level;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.phys.Vec3;
/** Handheld Linking Book; destination binding and travel are server-authoritative packets. */
public final class LinkingBookItem extends Item {
@@ -37,10 +39,13 @@ public final class LinkingBookItem extends Item {
return InteractionResult.SUCCESS;
}
public static String travel(ServerPlayer player, LinkBookTarget destination) {
public static String travel(ServerPlayer player, ItemStack book) {
LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (destination == null) return "Unbound Linking Book (26.3 port preview)";
if (!destination.valid()) return "Invalid Linking Book destination";
if (player.level().dimension().identifier().equals(destination.dimension())) {
if (player.level().dimension().identifier().equals(destination.dimension())
&& !properties.has(LinkProperties.INTRA_LINKING)) {
return "Same-dimension linking requires an Intra-Linking page";
}
ServerLevel target = player.level().getServer().getLevel(
@@ -61,11 +66,41 @@ public final class LinkingBookItem extends Item {
destination.z() - player.getZ()))) {
return "Linked destination is obstructed";
}
ServerLevel source = player.level();
Vec3 departure = player.position();
Vec3 incoming = player.getDeltaMovement();
boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(),
Set.of(), destination.yaw(), destination.pitch(), true);
if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw());
return moved ? null : "Teleportation failed";
}
/** Link Panel effects are shared by Linking and Descriptive Books. */
public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties,
ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw) {
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) {
double speed = Math.hypot(incoming.x, incoming.z);
double yaw = Math.toRadians(destinationYaw);
player.setDeltaMovement(speed * Math.cos(yaw), incoming.y + 0.2,
speed * Math.sin(yaw));
} else {
player.setDeltaMovement(Vec3.ZERO);
}
if (properties.has(LinkProperties.DISARM)) {
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) {
ItemStack carried = player.getInventory().getItem(slot);
if (carried.isEmpty()) continue;
player.getInventory().setItem(slot, ItemStack.EMPTY);
ItemEntity dropped = new ItemEntity(source, departure.x, departure.y, departure.z,
carried.copy());
dropped.setDefaultPickUpDelay();
source.addFreshEntity(dropped);
}
player.getInventory().setChanged();
player.containerMenu.broadcastChanges();
}
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
@@ -73,5 +108,12 @@ public final class LinkingBookItem extends Item {
tooltip.accept(Component.literal(target == null ? "Unbound (26.3 port preview)"
: "Destination: " + target.dimension() + " (" + (int) target.x() + ", "
+ (int) target.y() + ", " + (int) target.z() + ")"));
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (properties.has(LinkProperties.DISARM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm"));
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.maintain_momentum"));
if (properties.has(LinkProperties.INTRA_LINKING))
tooltip.accept(Component.translatable("item.rivencraft.link_property.intra_linking"));
}
}
@@ -2,8 +2,11 @@ package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.ChatFormatting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
@@ -16,8 +19,13 @@ public final class PageItem extends Item {
}
public static ItemStack createLinkPanelPage() {
return createLinkPanelPage(0);
}
public static ItemStack createLinkPanelPage(int flags) {
ItemStack stack = new ItemStack(Rivencraft.PAGE.get());
stack.set(Rivencraft.LINK_PANEL_PAGE.get(), true);
if (flags != 0) stack.set(Rivencraft.LINK_PROPERTIES.get(), new LinkProperties(flags));
return stack;
}
@@ -25,10 +33,28 @@ public final class PageItem extends Item {
return stack.is(Rivencraft.PAGE.get()) && stack.getOrDefault(Rivencraft.LINK_PANEL_PAGE.get(), false);
}
public static ItemStack createSymbolPage(Identifier id) {
if (PortSymbolPages.find(id) == null)
throw new IllegalArgumentException("Unknown bundled symbol " + id);
ItemStack stack = new ItemStack(Rivencraft.PAGE.get());
stack.set(Rivencraft.SYMBOL_PAGE_ID.get(), id);
return stack;
}
@Override
public Component getName(ItemStack stack) {
Component customName = stack.get(DataComponents.CUSTOM_NAME);
if (customName != null) return customName;
Identifier symbolId = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
if (symbolId != null) {
PortSymbolPages.Entry entry = PortSymbolPages.find(symbolId);
if (entry != null) {
Component name = Component.translatableWithFallback(
entry.nameKey(), PortSymbolPages.fallbackName(symbolId));
return isCorrupted(symbolId) ? name.copy().withStyle(ChatFormatting.OBFUSCATED) : name;
}
return Component.translatableWithFallback("item.rivencraft.page.unresolved", "Unknown Symbol Page");
}
return Component.translatable(isLinkPanel(stack)
? "item.rivencraft.page.link_panel" : "item.rivencraft.page.blank");
}
@@ -36,8 +62,33 @@ public final class PageItem extends Item {
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
Identifier symbolId = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
if (symbolId != null) {
PortSymbolPages.Entry entry = PortSymbolPages.find(symbolId);
if (entry != null) {
Component description = Component.translatableWithFallback(
entry.descriptionKey(), PortSymbolPages.fallbackName(symbolId));
tooltip.accept(isCorrupted(symbolId)
? description.copy().withStyle(ChatFormatting.GRAY, ChatFormatting.OBFUSCATED)
: description.copy().withStyle(ChatFormatting.GRAY));
}
tooltip.accept(Component.translatable("item.rivencraft.page.symbol.port_status")
.withStyle(ChatFormatting.DARK_GRAY));
return;
}
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (properties.has(LinkProperties.DISARM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm"));
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.maintain_momentum"));
if (properties.has(LinkProperties.INTRA_LINKING))
tooltip.accept(Component.translatable("item.rivencraft.link_property.intra_linking"));
tooltip.accept(Component.translatable(isLinkPanel(stack)
? "item.rivencraft.page.link_panel.port_status"
: "item.rivencraft.page.blank.port_status"));
}
private static boolean isCorrupted(Identifier id) {
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith("corrupted/");
}
}
@@ -0,0 +1,158 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.inventory.DataSlot;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Server-owned virtual output; taking it spends paper and the configured ink cost once. */
public final class InkMixerMenu extends AbstractContainerMenu {
public static final int MODIFIER = InkMixerBlockEntity.SIZE;
public static final int RESULT = MODIFIER + 1;
private static final int PLAYER_START = RESULT + 1;
private final InkMixerBlockEntity mixer;
private final Container inputs;
private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide;
private int inkRemaining;
private final int[] modifierChances = new int[3];
public InkMixerMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof InkMixerBlockEntity found ? found : null);
}
public InkMixerMenu(int id, Inventory inventory, InkMixerBlockEntity mixer) {
super(Rivencraft.INK_MIXER_MENU.get(), id);
this.mixer = mixer;
inputs = mixer == null ? new SimpleContainer(InkMixerBlockEntity.SIZE) : mixer;
clientSide = inventory.player.level().isClientSide();
addDataSlot(new DataSlot() {
@Override public int get() { return clientSide ? inkRemaining : mixer.inkRemaining(); }
@Override public void set(int value) { inkRemaining = value; }
});
for (int index = 0; index < modifierChances.length; index++) {
final int chanceIndex = index;
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide ? modifierChances[chanceIndex] : mixer.modifierChancePermille(chanceIndex);
}
@Override public void set(int value) { modifierChances[chanceIndex] = value; }
});
}
addSlot(new MixerSlot(InkMixerBlockEntity.INK, 8, 27));
addSlot(new MixerSlot(InkMixerBlockEntity.PAPER, 8, 48));
addSlot(new MixerSlot(InkMixerBlockEntity.BOTTLES, 152, 27));
addSlot(new ModifierSlot(79, 40));
addSlot(new ResultSlot(152, 48));
for (int row = 0; row < 3; row++)
for (int column = 0; column < 9; column++)
addSlot(new Slot(inventory, column + row * 9 + 9, 8 + column * 18, 99 + row * 18));
for (int column = 0; column < 9; column++)
addSlot(new Slot(inventory, column, 8 + column * 18, 157));
refreshResult();
}
public int inkRemaining() { return inkRemaining; }
public int modifierChancePermille(int index) { return modifierChances[index]; }
@Override public void clicked(int slotId, int button, ContainerInput clickType, Player player) {
if (slotId == MODIFIER) {
if (!clientSide && stillValid(player) && clickType == ContainerInput.PICKUP
&& (button == 0 || button == 1)) {
ItemStack carried = getCarried();
int amount = button == 1 ? 1 : carried.getCount();
if (mixer.addModifier(carried, amount)) {
setCarried(carried);
refreshResult();
broadcastChanges();
}
}
return;
}
super.clicked(slotId, button, clickType, player);
// In 26.3, recompute the virtual result in the same packet that changes
// paper or ink. Waiting for a later menu update can require a second click.
if (!clientSide) refreshResult();
}
@Override public boolean stillValid(Player player) {
return mixer != null && mixer.stillValid(player);
}
@Override public void broadcastChanges() {
refreshResult();
super.broadcastChanges();
}
private void refreshResult() {
if (clientSide) return;
ItemStack preview = mixer == null ? ItemStack.EMPTY : mixer.previewLinkPanel();
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
if (index == RESULT) {
refreshResult();
if (!mixer.canCreateLinkPanel()) return ItemStack.EMPTY;
ItemStack output = result.getItem(0).copy();
ItemStack remainder = output.copy();
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
return ItemStack.EMPTY;
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
refreshResult();
return output;
}
Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack source = slot.getItem();
ItemStack original = source.copy();
boolean moved;
if (index < MODIFIER) moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
else if (source.is(Rivencraft.INK_VIAL.get()))
moved = moveItemStackTo(source, InkMixerBlockEntity.INK, InkMixerBlockEntity.INK + 1, false);
else if (source.is(Items.PAPER))
moved = moveItemStackTo(source, InkMixerBlockEntity.PAPER, InkMixerBlockEntity.PAPER + 1, false);
else moved = false;
if (!moved) return ItemStack.EMPTY;
if (source.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
else slot.setChanged();
refreshResult();
return original;
}
private final class MixerSlot extends Slot {
MixerSlot(int index, int x, int y) { super(inputs, index, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return inputs.canPlaceItem(getContainerSlot(), stack); }
}
private static final class ModifierSlot extends Slot {
ModifierSlot(int x, int y) { super(new SimpleContainer(1), 0, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) { return false; }
}
private final class ResultSlot extends Slot {
ResultSlot(int x, int y) { super(result, 0, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) {
return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && mixer.consumeLinkPanelMaterials()) {
refreshResult();
}
super.onTake(player, stack);
}
}
}
@@ -0,0 +1,161 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Binder inputs persist in the block; assembling places the finished book on the cursor. */
public final class PortBookBinderMenu extends AbstractContainerMenu {
public static final int STRIP_CLICK_BASE = 3000;
private final Container binder;
private final PortBookBinderBlockEntity blockEntity;
private final String initialTitle;
public PortBookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof PortBookBinderBlockEntity found ? found : null, data.readUtf(32));
}
public PortBookBinderMenu(int id, Inventory inventory, PortBookBinderBlockEntity blockEntity) {
this(id, inventory, blockEntity, blockEntity == null ? "" : blockEntity.title());
}
private PortBookBinderMenu(int id, Inventory inventory, PortBookBinderBlockEntity blockEntity, String title) {
super(Rivencraft.BOOK_BINDER_MENU.get(), id);
this.blockEntity = blockEntity;
initialTitle = title;
binder = blockEntity == null ? new SimpleContainer(PortBookBinderBlockEntity.SIZE) : blockEntity;
addSlot(new Slot(binder, PortBookBinderBlockEntity.COVER, 8, 27) {
@Override public boolean mayPlace(ItemStack stack) { return stack.is(Items.LEATHER); }
});
addSlot(new Slot(binder, PortBookBinderBlockEntity.PANEL, -1000, -1000) {
@Override public boolean mayPlace(ItemStack stack) { return PageItem.isLinkPanel(stack); }
@Override public int getMaxStackSize() { return 1; }
});
addSlot(new Slot(binder, PortBookBinderBlockEntity.OUTPUT, 152, 27) {
@Override public boolean mayPlace(ItemStack stack) { return false; }
});
for (int page = 0; page < PortBookBinderBlockEntity.PAGE_COUNT; page++) {
addSlot(new Slot(binder, PortBookBinderBlockEntity.FIRST_PAGE + page, -1000, -1000) {
@Override public boolean mayPlace(ItemStack stack) {
return binder.canPlaceItem(getContainerSlot(), stack);
}
@Override public int getMaxStackSize() { return 1; }
});
}
addStandardInventorySlots(inventory, 8, 99);
}
@Override public boolean stillValid(Player player) {
return blockEntity != null && blockEntity.stillValid(player);
}
public String title() { return initialTitle; }
@Override public boolean clickMenuButton(Player player, int id) {
if (id < STRIP_CLICK_BASE || id > STRIP_CLICK_BASE + PortBookBinderBlockEntity.PAGE_COUNT
|| !stillValid(player)) return false;
if (player.level().isClientSide()) return true;
int index = id - STRIP_CLICK_BASE;
ItemStack carried = getCarried();
if (!carried.isEmpty()) {
if (index == 0) {
if (!PageItem.isLinkPanel(carried)
|| !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty()) return false;
binder.setItem(PortBookBinderBlockEntity.PANEL, carried.copyWithCount(1));
} else if (!blockEntity.insertPage(index - 1, carried.copyWithCount(1))) return false;
ItemStack remainder = carried.copy();
remainder.shrink(1);
setCarried(remainder);
} else {
ItemStack removed = index == 0
? binder.removeItem(PortBookBinderBlockEntity.PANEL, 1)
: blockEntity.takePage(index - 1);
if (removed.isEmpty()) return false;
setCarried(removed);
}
broadcastChanges();
return true;
}
public void setTitle(ServerPlayer player, String value) {
if (stillValid(player) && value != null && value.length() <= PortBookNetworking.MAX_BOOK_TITLE_LENGTH)
blockEntity.setTitle(value);
}
public void assemble(ServerPlayer player, String title) {
if (!stillValid(player) || title.isBlank() || title.length() > PortBookNetworking.MAX_BOOK_TITLE_LENGTH
|| !binder.getItem(PortBookBinderBlockEntity.OUTPUT).isEmpty()
|| !PortAuthoredWriting.supports(blockEntity.symbolPages())
|| !getCarried().isEmpty()) return;
ItemStack cover = binder.getItem(PortBookBinderBlockEntity.COVER);
ItemStack panel = binder.getItem(PortBookBinderBlockEntity.PANEL);
if (!cover.is(Items.LEATHER) || !PageItem.isLinkPanel(panel)) return;
blockEntity.setTitle(title);
ItemStack book = new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get());
var id = PortBookNetworking.createAge(player, title, blockEntity.symbolPages());
if (id == null) return;
book.set(Rivencraft.AGE_BOOK_ID.get(), id);
book.set(DataComponents.CUSTOM_NAME, Component.literal(title));
if (blockEntity.hasSymbolPages())
book.set(Rivencraft.BOOK_SYMBOLS.get(), blockEntity.symbolPages());
var modifiers = panel.get(Rivencraft.LINK_PROPERTIES.get());
if (modifiers != null) book.set(Rivencraft.LINK_PROPERTIES.get(), modifiers);
cover.shrink(1);
panel.shrink(1);
binder.setItem(PortBookBinderBlockEntity.COVER, cover);
binder.setItem(PortBookBinderBlockEntity.PANEL, panel);
blockEntity.clearSymbolPages();
setCarried(book);
broadcastChanges();
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
if (index >= PortBookBinderBlockEntity.FIRST_PAGE && index < PortBookBinderBlockEntity.SIZE) {
ItemStack page = slot.getItem().copy();
ItemStack remainder = page.copy();
if (!moveItemStackTo(remainder, PortBookBinderBlockEntity.SIZE, slots.size(), true)
|| !remainder.isEmpty()) return ItemStack.EMPTY;
blockEntity.takePage(index - PortBookBinderBlockEntity.FIRST_PAGE);
broadcastChanges();
return page;
}
ItemStack stack = slot.getItem();
ItemStack original = stack.copy();
boolean moved;
if (index < PortBookBinderBlockEntity.SIZE) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.SIZE, slots.size(), true);
} else if (stack.is(Items.LEATHER)) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.COVER,
PortBookBinderBlockEntity.COVER + 1, false);
} else if (PageItem.isLinkPanel(stack)) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.PANEL,
PortBookBinderBlockEntity.PANEL + 1, false);
} else if (stack.is(Rivencraft.PAGE.get()) && stack.has(Rivencraft.SYMBOL_PAGE_ID.get())) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.FIRST_PAGE,
PortBookBinderBlockEntity.SIZE, false);
} else return ItemStack.EMPTY;
if (!moved) return ItemStack.EMPTY;
if (stack.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
else slot.setChanged();
slot.onTake(player, stack);
return original;
}
}
@@ -0,0 +1,17 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record AssembleBinderPayload(String title) implements CustomPacketPayload {
public static final Type<AssembleBinderPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "assemble_binder"));
public static final StreamCodec<RegistryFriendlyByteBuf, AssembleBinderPayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.stringUtf8(32), AssembleBinderPayload::title,
AssembleBinderPayload::new);
@Override public Type<AssembleBinderPayload> type() { return TYPE; }
}
@@ -4,7 +4,10 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortAgeTravel;
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
@@ -13,8 +16,11 @@ import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.phys.Vec3;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
import java.util.Locale;
import java.util.List;
/** Server-authoritative handheld Linking Book binding and travel. */
public final class PortBookNetworking {
@@ -41,6 +47,21 @@ public final class PortBookNetworking {
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) enterAge(player, payload);
});
registrar.playToServer(AssembleBinderPayload.TYPE, AssembleBinderPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof PortBookBinderMenu menu) {
menu.assemble(player, payload.title().strip());
}
});
registrar.playToServer(SetBinderTitlePayload.TYPE, SetBinderTitlePayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof PortBookBinderMenu menu
&& menu.containerId == payload.containerId()) {
menu.setTitle(player, payload.title());
}
});
}
private static void bind(ServerPlayer player, BindLinkingBookPayload payload) {
@@ -59,7 +80,7 @@ public final class PortBookNetworking {
private static void use(ServerPlayer player, UseLinkingBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.LINKING_BOOK.get())) return;
String failure = LinkingBookItem.travel(player, stack.get(Rivencraft.LINK_BOOK_TARGET.get()));
String failure = LinkingBookItem.travel(player, stack);
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
}
@@ -68,6 +89,30 @@ public final class PortBookNetworking {
if (!stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()) || stack.has(Rivencraft.AGE_BOOK_ID.get())) return;
String title = payload.title().strip();
if (title.isEmpty() || title.length() > MAX_BOOK_TITLE_LENGTH) return;
Identifier id = createAge(player, title);
if (id == null) return;
stack.set(Rivencraft.AGE_BOOK_ID.get(), id);
stack.set(DataComponents.CUSTOM_NAME, Component.literal(title));
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN,
SoundSource.PLAYERS, 1.0F, 1.0F);
player.sendOverlayMessage(Component.literal("Bound Descriptive Book: " + title));
player.containerMenu.broadcastChanges();
}
/** Shared name resolution and Age creation for handheld books and the Binder. */
public static Identifier createAge(ServerPlayer player, String title) {
return createAge(player, title, List.of());
}
public static Identifier createAge(ServerPlayer player, String title, List<Identifier> pages) {
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
net.dnorberg.rivencraft.age.PortBiomePlan biomePlan;
try {
biomePlan = PortAuthoredWriting.biomePlan(pages, player.registryAccess());
} catch (IllegalArgumentException exception) {
player.sendOverlayMessage(Component.literal("Age writing unavailable: " + exception.getMessage()));
return null;
}
String base = title.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "_")
.replaceAll("^[_\\.-]+|[_\\.-]+$", "");
if (base.isEmpty()) base = "age";
@@ -80,17 +125,13 @@ public final class PortBookNetworking {
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
}
try {
RuntimeAgeBackend.create(server, id, player.getRandom().nextLong());
RuntimeAgeBackend.create(server, id, player.getRandom().nextLong(), biomePlan,
PortAuthoredWriting.terrain(pages));
} catch (IllegalArgumentException | IllegalStateException exception) {
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
return;
return null;
}
stack.set(Rivencraft.AGE_BOOK_ID.get(), id);
stack.set(DataComponents.CUSTOM_NAME, Component.literal(title));
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN,
SoundSource.PLAYERS, 1.0F, 1.0F);
player.sendOverlayMessage(Component.literal("Bound Descriptive Book: " + title));
player.containerMenu.broadcastChanges();
return id;
}
private static void enterAge(ServerPlayer player, UseDescriptiveBookPayload payload) {
@@ -98,8 +139,14 @@ public final class PortBookNetworking {
if (!stack.is(Rivencraft.DESCRIPTIVE_BOOK.get())) return;
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
if (id == null) return;
ServerLevel source = player.level();
Vec3 departure = player.position();
Vec3 incoming = player.getDeltaMovement();
String failure = PortAgeTravel.enter(player, id);
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
else LinkingBookItem.applySuccessfulLink(player,
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
source, departure, incoming, player.getYRot());
}
private static InteractionHand hand(boolean offhand) {
@@ -0,0 +1,18 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record SetBinderTitlePayload(int containerId, String title) implements CustomPacketPayload {
public static final Type<SetBinderTitlePayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_binder_title"));
public static final StreamCodec<RegistryFriendlyByteBuf, SetBinderTitlePayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetBinderTitlePayload::containerId,
ByteBufCodecs.stringUtf8(32), SetBinderTitlePayload::title,
SetBinderTitlePayload::new);
@Override public Type<SetBinderTitlePayload> type() { return TYPE; }
}
@@ -2,6 +2,8 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.CreativeModeTab;
@@ -28,13 +30,36 @@ public final class ModCreativeTabs {
output.accept(Rivencraft.CRYSTAL_CLUSTER_ITEM.get());
output.accept(Rivencraft.CRYSTAL_SHARD.get());
output.accept(Rivencraft.INK_VIAL.get());
output.accept(Rivencraft.BOOK_BINDER_ITEM.get());
output.accept(Rivencraft.INK_MIXER_ITEM.get());
})
.build());
TABS.register("rivencraft_link_pages", () -> CreativeModeTab.builder()
.title(Component.translatable("itemGroup.rivencraft.pages.link"))
.icon(PageItem::createLinkPanelPage)
.displayItems((parameters, output) -> output.accept(PageItem.createLinkPanelPage()))
.displayItems((parameters, output) -> {
int[] flags = {LinkProperties.DISARM, LinkProperties.MAINTAIN_MOMENTUM,
LinkProperties.INTRA_LINKING};
for (int combination = 0; combination < 8; combination++) {
int selected = 0;
for (int bit = 0; bit < flags.length; bit++)
if ((combination & (1 << bit)) != 0) selected |= flags[bit];
output.accept(PageItem.createLinkPanelPage(selected));
}
})
.build());
for (String category : PortSymbolPages.categories()) {
String path = category.substring(category.indexOf(':') + 1);
TABS.register("rivencraft_pages_" + path, () -> CreativeModeTab.builder()
.title(Component.translatableWithFallback("itemGroup.rivencraft.pages." + path,
"Rivencraft " + PortSymbolPages.fallbackName(
net.minecraft.resources.Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path))
+ " Pages"))
.icon(() -> PortSymbolPages.icon(category))
.displayItems((parameters, output) ->
PortSymbolPages.pages(category, parameters.holders()).forEach(output::accept))
.build());
}
}
private ModCreativeTabs() {
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.registry;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortScaledBiomeSource;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.biome.BiomeSource;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredRegister;
/** Serializes the custom biome resolver in saved 26.3 Age level stems. */
public final class PortBiomeSources {
private static final DeferredRegister<MapCodec<? extends BiomeSource>> SOURCES =
DeferredRegister.create(Registries.BIOME_SOURCE, Rivencraft.MOD_ID);
static {
SOURCES.register("scaled", () -> PortScaledBiomeSource.CODEC);
}
private PortBiomeSources() {}
public static void register(IEventBus bus) {
SOURCES.register(bus);
}
}
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.registry;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortFlatChunkGenerator;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredRegister;
/** Codec for the authored Flat/Void biome source and layer combination. */
public final class PortChunkGenerators {
private static final DeferredRegister<MapCodec<? extends ChunkGenerator>> GENERATORS =
DeferredRegister.create(Registries.CHUNK_GENERATOR, Rivencraft.MOD_ID);
static {
GENERATORS.register("age_flat", () -> PortFlatChunkGenerator.CODEC);
}
private PortChunkGenerators() {}
public static void register(IEventBus bus) {
GENERATORS.register(bus);
}
}
@@ -0,0 +1,31 @@
package net.dnorberg.rivencraft.writing;
import java.util.List;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Built-in Legacy ink additives, carried forward from the 1.21.1 mixer. */
public final class InkModifierIngredients {
public record Effect(int flag, double chance) {}
public record Ingredient(ItemStack item, List<Effect> effects) {}
private static final List<Ingredient> BUILT_IN = List.of(
new Ingredient(new ItemStack(Items.GUNPOWDER), List.of(new Effect(LinkProperties.DISARM, 0.20))),
new Ingredient(new ItemStack(Items.MUSHROOM_STEW), List.of(new Effect(LinkProperties.DISARM, 0.05))),
new Ingredient(new ItemStack(Items.EXPERIENCE_BOTTLE), List.of(new Effect(LinkProperties.INTRA_LINKING, 0.15))),
new Ingredient(new ItemStack(Items.ENDER_PEARL), List.of(
new Effect(LinkProperties.INTRA_LINKING, 0.15),
new Effect(LinkProperties.DISARM, 0.15))),
new Ingredient(new ItemStack(Items.FEATHER), List.of(new Effect(LinkProperties.MAINTAIN_MOMENTUM, 0.15))),
new Ingredient(new ItemStack(Items.FIRE_CHARGE), List.of(new Effect(LinkProperties.DISARM, 0.25)))
);
private InkModifierIngredients() {}
public static List<Ingredient> builtIn() { return BUILT_IN; }
public static Ingredient find(ItemStack stack) {
return BUILT_IN.stream().filter(ingredient -> stack.is(ingredient.item().getItem())).findFirst().orElse(null);
}
}
@@ -0,0 +1,43 @@
package net.dnorberg.rivencraft.writing;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.minecraft.core.NonNullList;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Shared, server-side vial-to-meter exchange from the 1.21.1 Desk and Mixer. */
public final class InkVialExchange {
private InkVialExchange() {}
public static boolean canPay(NonNullList<ItemStack> items, int inkSlot, int bottleSlot,
int storedInk, int cost) {
return storedInk >= cost || canExchange(items, inkSlot, bottleSlot)
&& storedInk + vialInk(items.get(inkSlot)) >= cost;
}
/** Returns added ink; zero means no inputs were changed. */
public static int exchange(NonNullList<ItemStack> items, int inkSlot, int bottleSlot) {
if (!canExchange(items, inkSlot, bottleSlot)) return 0;
ItemStack vial = items.get(inkSlot);
int addedInk = vialInk(vial);
vial.shrink(1);
if (vial.isEmpty()) items.set(inkSlot, ItemStack.EMPTY);
ItemStack bottles = items.get(bottleSlot);
if (bottles.isEmpty()) items.set(bottleSlot, new ItemStack(Items.GLASS_BOTTLE));
else bottles.grow(1);
return addedInk;
}
private static boolean canExchange(NonNullList<ItemStack> items, int inkSlot, int bottleSlot) {
ItemStack vial = items.get(inkSlot);
ItemStack bottles = items.get(bottleSlot);
return vial.is(Rivencraft.INK_VIAL.get()) && vialInk(vial) > 0
&& (bottles.isEmpty() || bottles.is(Items.GLASS_BOTTLE)
&& bottles.getCount() < bottles.getMaxStackSize());
}
private static int vialInk(ItemStack vial) {
return Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
}
}
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
@@ -53,6 +54,8 @@ public final class LinkingBookRecipe extends CustomRecipe {
if (panel.has(DataComponents.CUSTOM_NAME)) {
book.set(DataComponents.CUSTOM_NAME, panel.get(DataComponents.CUSTOM_NAME));
}
LinkProperties properties = panel.get(Rivencraft.LINK_PROPERTIES.get());
if (properties != null) book.set(Rivencraft.LINK_PROPERTIES.get(), properties);
return book;
}
@@ -0,0 +1,121 @@
package net.dnorberg.rivencraft.writing;
import java.util.List;
import java.util.ArrayList;
import net.dnorberg.rivencraft.age.PortBiomePlan;
import net.dnorberg.rivencraft.age.PortAgeTerrain;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
/** Authored noise terrain and biome distribution supported by the 26.3 backend. */
public final class PortAuthoredWriting {
private static final int MAX_AUTHORED_BIOMES = 252;
private static final Identifier STANDARD_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/standard");
private static final Identifier AMPLIFIED_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/amplified");
private static final Identifier ISLAND_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/island");
private static final Identifier FLAT_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/flat");
private static final Identifier VOID_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/void");
private static final Identifier CAVE_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/cave");
private PortAuthoredWriting() {}
/** A page never disappears into an Age unless this port can apply its effect. */
public static boolean supports(List<Identifier> pages) {
boolean terrain = false;
int biomes = 0;
int controllers = 0;
for (Identifier page : pages) {
if (isTerrain(page) && !terrain) {
terrain = true;
} else if (controller(page) != null && controllers++ == 0) {
// An explicit controller needs an authored pool.
} else if (page.getNamespace().equals(Rivencraft.MOD_ID)
&& page.getPath().startsWith("biome/") && biomes < MAX_AUTHORED_BIOMES
&& PortSymbolPages.find(page) != null
&& PortSymbolPages.find(page).category().equals("rivencraft:biome")
&& PortRegistryPages.biomeId(page) != null) {
biomes++;
} else return false;
}
if (controllers != 0) {
PortBiomePlan.Distribution distribution = pages.stream().map(PortAuthoredWriting::controller)
.filter(value -> value != null).findFirst().orElseThrow();
boolean valid = switch (distribution) {
case NATIVE -> biomes == 0;
case SINGLE -> biomes == 1;
default -> biomes >= 2;
};
return valid;
}
return true;
}
private static boolean isTerrain(Identifier page) {
return page.equals(STANDARD_TERRAIN) || page.equals(AMPLIFIED_TERRAIN)
|| page.equals(ISLAND_TERRAIN) || page.equals(FLAT_TERRAIN)
|| page.equals(VOID_TERRAIN) || page.equals(CAVE_TERRAIN);
}
private static PortBiomePlan.Distribution controller(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
return switch (page.getPath()) {
case "biome/native" -> PortBiomePlan.Distribution.NATIVE;
case "biome/single" -> PortBiomePlan.Distribution.SINGLE;
case "biome/tiled" -> PortBiomePlan.Distribution.TILED;
case "biome/grid" -> PortBiomePlan.Distribution.GRID;
case "biome/tiny" -> PortBiomePlan.Distribution.TINY;
case "biome/small" -> PortBiomePlan.Distribution.SMALL;
case "biome/medium" -> PortBiomePlan.Distribution.MEDIUM;
case "biome/large" -> PortBiomePlan.Distribution.LARGE;
case "biome/huge" -> PortBiomePlan.Distribution.HUGE;
default -> null;
};
}
public static PortAgeTerrain terrain(List<Identifier> pages) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
if (pages.contains(AMPLIFIED_TERRAIN)) return PortAgeTerrain.AMPLIFIED;
if (pages.contains(ISLAND_TERRAIN)) return PortAgeTerrain.ISLAND;
if (pages.contains(FLAT_TERRAIN)) return PortAgeTerrain.FLAT;
if (pages.contains(VOID_TERRAIN)) return PortAgeTerrain.VOID;
if (pages.contains(CAVE_TERRAIN)) return PortAgeTerrain.CAVE;
return PortAgeTerrain.STANDARD;
}
/** Resolves all authored biomes from the active registry before creating a level. */
public static PortBiomePlan biomePlan(List<Identifier> pages, HolderLookup.Provider registries) {
if (!supports(pages)) throw new IllegalArgumentException(
"Unsupported terrain or biome-page combination in this port slice");
List<Identifier> biomes = new ArrayList<>();
PortBiomePlan.Distribution distribution = null;
boolean caveTerrain = pages.contains(CAVE_TERRAIN);
for (Identifier page : pages) {
if (isTerrain(page)) continue;
PortBiomePlan.Distribution selected = controller(page);
if (selected != null) { distribution = selected; continue; }
Identifier biome = PortRegistryPages.biomeId(page);
var registry = registries.lookupOrThrow(Registries.BIOME);
var holder = registry.get(ResourceKey.create(Registries.BIOME, biome))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biome));
if (caveTerrain && !PortRegistryPages.isCaveBiome(holder))
throw new IllegalArgumentException("Cave terrain requires a Cave or Nether biome: " + biome);
biomes.add(biome);
}
if (distribution == PortBiomePlan.Distribution.NATIVE)
return new PortBiomePlan(distribution, List.of());
if (biomes.isEmpty()) return null;
if (distribution == null) distribution = biomes.size() == 1
? PortBiomePlan.Distribution.SINGLE : biomes.size() == 2
? PortBiomePlan.Distribution.TILED : PortBiomePlan.Distribution.MEDIUM;
return new PortBiomePlan(distribution, biomes);
}
}
@@ -0,0 +1,136 @@
package net.dnorberg.rivencraft.writing;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.tags.BiomeTags;
import net.minecraft.tags.TagKey;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.EmptyBlockGetter;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.EntityBlock;
/** Registry-derived 26.3 biome and material pages, with the same IDs as 1.21.1. */
public final class PortRegistryPages {
private static final TagKey<net.minecraft.world.level.biome.Biome> CAVE_BIOMES = TagKey.create(
Registries.BIOME, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "cave_biomes"));
private static final Identifier DEFERRED_PLACEHOLDER = Identifier.fromNamespaceAndPath(
"terrablender", "deferred_placeholder");
private PortRegistryPages() {}
public static Identifier biomePageId(Identifier biome) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome/"
+ (biome.getNamespace().equals("minecraft") ? "" : biome.getNamespace() + "/")
+ biome.getPath());
}
public static Identifier blockPageId(Identifier block) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"block/" + block.getNamespace() + "/" + block.getPath());
}
public static PortSymbolPages.Entry find(Identifier symbol) {
if (!symbol.getNamespace().equals(Rivencraft.MOD_ID)) return null;
String path = symbol.getPath();
if (path.startsWith("biome/")) {
Identifier biome = biomeId(symbol);
if (biome == null) return null;
String key = biome.getNamespace().equals("minecraft")
? "symbol.rivencraft.biome." + biome.getPath()
: "biome." + biome.getNamespace() + "." + biome.getPath().replace('/', '.');
return new PortSymbolPages.Entry(symbol, "rivencraft:biome", key,
"symbol.rivencraft.biome.generated.description",
"Nature Form " + glyphWord(biome.getPath(), "World") + " Biome");
}
if (path.startsWith("block/")) {
Identifier block = blockId(symbol);
if (block == null) return null;
String category = materialCategory(BuiltInRegistries.BLOCK.getValue(block));
if (category == null) return null;
return new PortSymbolPages.Entry(symbol, category,
"block." + block.getNamespace() + "." + block.getPath().replace('/', '_'),
"symbol.rivencraft.block.generated.description",
"Modifier Constraint " + glyphWord(block.getPath(), "Stone") + " Form");
}
return null;
}
public static List<ItemStack> biomePages(HolderLookup.Provider registries, boolean caves) {
return registries.lookupOrThrow(Registries.BIOME).listElements()
.filter(holder -> !holder.key().identifier().equals(DEFERRED_PLACEHOLDER))
.filter(holder -> isCaveBiome(holder) == caves)
.map(holder -> PageItem.createSymbolPage(biomePageId(holder.key().identifier())))
.sorted((left, right) -> left.get(Rivencraft.SYMBOL_PAGE_ID.get()).toString()
.compareTo(right.get(Rivencraft.SYMBOL_PAGE_ID.get()).toString()))
.toList();
}
public static List<ItemStack> materialPages(boolean fluids) {
String category = fluids ? "rivencraft:fluid" : "rivencraft:block";
return BuiltInRegistries.BLOCK.keySet().stream().sorted()
.filter(id -> category.equals(materialCategory(BuiltInRegistries.BLOCK.getValue(id))))
.map(id -> PageItem.createSymbolPage(blockPageId(id))).toList();
}
public static boolean isCaveBiome(net.minecraft.core.Holder.Reference<net.minecraft.world.level.biome.Biome> holder) {
String path = holder.key().identifier().getPath();
return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER)
|| path.equals("deep_dark") || path.endsWith("_caves");
}
private static String materialCategory(Block block) {
if (block == null || block == Blocks.AIR || block == Blocks.CAVE_AIR || block == Blocks.VOID_AIR
|| block instanceof EntityBlock || block.defaultBlockState().hasBlockEntity()) return null;
var state = block.defaultBlockState();
var fluid = state.getFluidState();
if (fluid.isSource() && fluid.createLegacyBlock().getBlock() == block) return "rivencraft:fluid";
return fluid.isEmpty() && state.isCollisionShapeFullBlock(EmptyBlockGetter.INSTANCE, BlockPos.ZERO)
? "rivencraft:block" : null;
}
public static Identifier biomeId(Identifier symbol) {
String path = symbol.getPath().substring("biome/".length());
int slash = path.indexOf('/');
if (slash < 0) return Identifier.fromNamespaceAndPath("minecraft", path);
return Identifier.fromNamespaceAndPath(path.substring(0, slash), path.substring(slash + 1));
}
public static Identifier blockId(Identifier symbol) {
String path = symbol.getPath().substring("block/".length());
int slash = path.indexOf('/');
if (slash < 1) return null;
return Identifier.fromNamespaceAndPath(path.substring(0, slash), path.substring(slash + 1));
}
public static String fallbackName(Identifier symbol) {
boolean biome = symbol.getPath().startsWith("biome/");
Identifier source = biome ? biomeId(symbol) : blockId(symbol);
if (source == null) return symbol.toString();
String words = Arrays.stream(source.getPath().replace('/', '_').split("_"))
.filter(word -> !word.isEmpty())
.map(word -> word.substring(0, 1).toUpperCase(Locale.ROOT) + word.substring(1))
.reduce((a, b) -> a + " " + b).orElse("Unknown");
return words + (biome ? " Biome" : "rivencraft:fluid".equals(find(symbol).category())
? " Fluid" : " Material")
+ (source.getNamespace().equals("minecraft") ? "" : " (" + source.getNamespace() + ")");
}
private static String glyphWord(String path, String fallback) {
String word = Arrays.stream(path.replace('/', '_').split("_"))
.map(part -> part.replaceAll("[^A-Za-z]", ""))
.filter(part -> !part.isEmpty())
.map(part -> part.substring(0, 1).toUpperCase(Locale.ROOT) + part.substring(1))
.reduce("", String::concat);
if (word.isEmpty()) word = fallback;
return word.length() > 48 ? word.substring(0, 48) : word;
}
}
@@ -0,0 +1,16 @@
package net.dnorberg.rivencraft.writing;
/** Mystcraft Legacy's layered base-25 numeral components, shared with 1.21.1. */
public final class PortSymbolNumerals {
private PortSymbolNumerals() {}
public static int[] componentsForDigit(int digit) {
if (digit < 0 || digit >= 25) throw new IllegalArgumentException("Base-25 digit must be in [0, 24]");
if (digit == 0) return new int[] {1};
int fifths = digit / 5;
int remainder = digit % 5;
if (fifths == 0) return new int[] {55 + remainder};
if (remainder == 0) return new int[] {59 + fifths};
return new int[] {59 + fifths, 55 + remainder};
}
}
@@ -0,0 +1,101 @@
package net.dnorberg.rivencraft.writing;
import com.google.gson.JsonParser;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashSet;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
import net.minecraft.core.HolderLookup;
import net.minecraft.world.item.ItemStack;
import net.dnorberg.rivencraft.item.PageItem;
/** Bundled 1.21.1 symbol identities, shared by the port's creative pages. */
public final class PortSymbolPages {
public record Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem) {}
private static final Map<Identifier, Entry> ENTRIES = load();
private PortSymbolPages() {}
public static Entry find(Identifier id) {
Entry bundled = ENTRIES.get(id);
return bundled != null ? bundled : PortRegistryPages.find(id);
}
public static List<String> categories() {
LinkedHashSet<String> categories = new LinkedHashSet<>();
ENTRIES.values().stream().map(Entry::category).forEach(categories::add);
categories.add("rivencraft:cave_biome");
categories.add("rivencraft:block");
categories.add("rivencraft:fluid");
return List.copyOf(categories);
}
public static List<ItemStack> pages(String category, HolderLookup.Provider registries) {
List<ItemStack> pages = new ArrayList<>();
ENTRIES.values().stream().filter(entry -> entry.category().equals(category))
.filter(entry -> !category.equals("rivencraft:biome")
|| !entry.id().getPath().startsWith("biome/"))
.forEach(entry -> pages.add(PageItem.createSymbolPage(entry.id())));
if (category.equals("rivencraft:biome")) pages.addAll(PortRegistryPages.biomePages(registries, false));
if (category.equals("rivencraft:cave_biome")) pages.addAll(PortRegistryPages.biomePages(registries, true));
if (category.equals("rivencraft:block")) pages.addAll(PortRegistryPages.materialPages(false));
if (category.equals("rivencraft:fluid")) pages.addAll(PortRegistryPages.materialPages(true));
return List.copyOf(pages);
}
public static ItemStack icon(String category) {
if (category.equals("rivencraft:cave_biome"))
return PageItem.createSymbolPage(PortRegistryPages.biomePageId(
Identifier.fromNamespaceAndPath("minecraft", "lush_caves")));
if (category.equals("rivencraft:block") || category.equals("rivencraft:fluid"))
return PageItem.createSymbolPage(PortRegistryPages.blockPageId(
Identifier.fromNamespaceAndPath("minecraft",
category.equals("rivencraft:fluid") ? "water" : "stone")));
return ENTRIES.values().stream().filter(entry -> entry.category().equals(category))
.findFirst().map(entry -> PageItem.createSymbolPage(entry.id()))
.orElseGet(PageItem::createLinkPanelPage);
}
public static String fallbackName(Identifier id) {
if (id.getPath().startsWith("biome/") || id.getPath().startsWith("block/"))
return PortRegistryPages.fallbackName(id);
String path = id.getPath();
String stem = path.substring(path.lastIndexOf('/') + 1).replace('_', ' ');
if (stem.isEmpty()) return id.toString();
return Character.toUpperCase(stem.charAt(0)) + stem.substring(1);
}
private static Map<Identifier, Entry> load() {
String resource = "/assets/rivencraft/creative_symbol_pages.json";
try (var stream = PortSymbolPages.class.getResourceAsStream(resource)) {
if (stream == null) throw new IllegalStateException("Missing generated symbol index " + resource);
var json = JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8))
.getAsJsonObject();
Map<Identifier, Entry> entries = new LinkedHashMap<>();
json.entrySet().forEach(item -> {
String[] parts = item.getKey().split(":", 2);
if (parts.length != 2 || !parts[0].equals(Rivencraft.MOD_ID))
throw new IllegalStateException("Unexpected bundled symbol " + item.getKey());
Identifier id = Identifier.fromNamespaceAndPath(parts[0], parts[1]);
var definition = item.getValue().getAsJsonObject();
String category = definition.get("category").getAsString();
if (definition.has("semantics") && definition.getAsJsonObject("semantics")
.get("role").getAsString().equals("biome_controller"))
category = "rivencraft:biome_generation";
entries.put(id, new Entry(id, category, definition.get("name").getAsString(),
definition.get("description").getAsString(),
definition.has("poem") ? definition.get("poem").getAsString() : ""));
});
return Collections.unmodifiableMap(entries);
} catch (java.io.IOException exception) {
throw new IllegalStateException("Could not read bundled symbol index", exception);
}
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/book_binder"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/ink_mixer"
}
}
@@ -4,6 +4,16 @@
"property": "minecraft:has_component",
"component": "rivencraft:link_panel_page",
"on_true": {"type": "minecraft:model", "model": "rivencraft:item/page_link_panel"},
"on_false": {"type": "minecraft:model", "model": "rivencraft:item/page"}
"on_false": {
"type": "minecraft:condition",
"property": "minecraft:has_component",
"component": "rivencraft:symbol_page_id",
"on_true": {
"type": "minecraft:special",
"base": "rivencraft:item/page",
"model": {"type": "rivencraft:symbol_page"}
},
"on_false": {"type": "minecraft:model", "model": "rivencraft:item/page"}
}
}
}
@@ -1,21 +0,0 @@
{
"item.rivencraft.crystal_shard": "Crystal Shard",
"item.rivencraft.descriptive_book": "Descriptive Book",
"item.rivencraft.linking_book": "Linking Book",
"item.rivencraft.page": "Page",
"item.rivencraft.page.blank": "Blank Page",
"item.rivencraft.page.link_panel": "Link Panel Page",
"item.rivencraft.ink_vial": "Ink Vial",
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
"item.rivencraft.ink_vial.usage": "Use with paper in the other hand to prepare a Link Panel.",
"item.rivencraft.page.blank.port_status": "Base for future symbol writing; not yet usable in this port.",
"item.rivencraft.page.link_panel.port_status": "Prepared for book binding; the Book Binder is not yet ported.",
"block.rivencraft.crystal_block": "Crystal Block",
"block.rivencraft.budding_crystal": "Budding Crystal",
"block.rivencraft.small_crystal_bud": "Small Crystal Bud",
"block.rivencraft.medium_crystal_bud": "Medium Crystal Bud",
"block.rivencraft.large_crystal_bud": "Large Crystal Bud",
"block.rivencraft.crystal_cluster": "Crystal Cluster",
"itemGroup.rivencraft": "Rivencraft",
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages"
}
@@ -0,0 +1,10 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"pattern": ["III", "PPP", "P P"],
"key": {
"I": "minecraft:iron_ingot",
"P": "#minecraft:planks"
},
"result": {"id": "rivencraft:book_binder", "count": 1}
}
@@ -0,0 +1,11 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"pattern": ["S S", "SVS", "PSP"],
"key": {
"S": "minecraft:stone",
"V": "minecraft:glass_bottle",
"P": "#minecraft:planks"
},
"result": {"id": "rivencraft:ink_mixer", "count": 1}
}
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.abandoned_camps",
"description": "symbol.rivencraft.feature.abandoned_camps.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:abandoned_camps" },
"poem": "Terrain Form Abandoned Camp"
}
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.fallen_poplar_trees",
"description": "symbol.rivencraft.feature.fallen_poplar_trees.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:fallen_poplar_trees" },
"poem": "Nature Form Poplar Fallen"
}
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.poplar_trees",
"description": "symbol.rivencraft.feature.poplar_trees.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:poplar_trees" },
"poem": "Nature Form Poplar Growth"
}
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.red_shrubs",
"description": "symbol.rivencraft.feature.red_shrubs.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:red_shrubs" },
"poem": "Nature Form Red Growth"
}
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.shelf_mushrooms",
"description": "symbol.rivencraft.feature.shelf_mushrooms.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:shelf_mushrooms" },
"poem": "Nature Form Shelf Fungi"
}
@@ -0,0 +1,8 @@
{
"replace": false,
"values": [
"minecraft:lush_caves",
"minecraft:dripstone_caves",
"minecraft:deep_dark"
]
}
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""Publish a Rivencraft JAR to Gitea, Modrinth, and/or CurseForge.
Preview is the default. Tokens live in the Secret Service keyring, never in git.
"""
import argparse
import getpass
import json
import mimetypes
import os
from pathlib import Path
import secrets
import subprocess
import sys
import urllib.error
import urllib.request
MODRINTH_PROJECT = "GH2xfZJ8"
CURSEFORGE_PROJECT = "1766326"
GITEA_API = "https://git.paddedcell.org/api/v1/repos/dnorberg/rivencraft"
USER_AGENT = "RivencraftReleaseTool/1.0 (https://git.paddedcell.org/dnorberg/rivencraft)"
SITES = ("gitea", "modrinth", "curseforge")
def token_for(site: str) -> str:
environment = os.environ.get(f"RIVENCRAFT_{site.upper()}_TOKEN", "").strip()
if environment:
return environment
result = subprocess.run(
["secret-tool", "lookup", "application", "rivencraft-publishing", "site", site],
text=True,
capture_output=True,
check=False,
)
if result.returncode:
raise RuntimeError(f"Could not read {site} token from the system keyring")
token = result.stdout.strip()
if not token:
raise RuntimeError(f"No {site} token found; run `store-token {site}` first")
return token
def store_token(site: str) -> None:
token = getpass.getpass(f"Paste the {site} API token (input hidden): ").strip()
if not token:
raise RuntimeError("Empty token; nothing was stored")
subprocess.run(
[
"secret-tool", "store", "--label", f"Rivencraft {site} publishing token",
"application", "rivencraft-publishing", "site", site,
],
input=token + "\n",
text=True,
check=True,
)
print(f"Stored {site} token in the system keyring")
def check_token(site: str) -> None:
token = token_for(site)
if site == "gitea":
account = request_json("https://git.paddedcell.org/api/v1/token", token=f"token {token}")
scopes = account.get("scopes", [])
if "write:repository" not in scopes and "all" not in scopes:
raise RuntimeError("Gitea token is valid but lacks write:repository scope")
print(f"Gitea API access works for {account['user']['login']} (write:repository)")
elif site == "modrinth":
try:
account = request_json("https://api.modrinth.com/v2/user", token=token)
except RuntimeError as error:
raise RuntimeError(
"Modrinth check failed. The token needs USER_READ for this "
"read-only check, plus VERSION_CREATE for publishing. "
f"API response: {error}"
) from None
print(f"Modrinth API access works for {account['username']}")
else:
versions = request_json(
"https://www.curseforge.com/api/game/versions",
token=token, token_header="X-Api-Token",
)
if not isinstance(versions, list):
raise RuntimeError("CurseForge returned an unexpected response")
print("CurseForge API token accepted")
def request_json(url: str, *, token: str | None = None, token_header: str = "Authorization",
payload: bytes | None = None, content_type: str | None = None,
method: str = "GET", not_found_ok: bool = False) -> dict | list | None:
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
if token:
headers[token_header] = token
if content_type:
headers["Content-Type"] = content_type
request = urllib.request.Request(url, data=payload, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code == 404 and not_found_ok:
return None
# Error bodies can contain server details, but never echo tokens.
detail = error.read(600).decode("utf-8", "replace")
raise RuntimeError(f"{url}: HTTP {error.code}: {detail}") from None
def multipart(fields: dict[str, str], jar: Path, *, file_field: str = "file") -> tuple[bytes, str]:
boundary = "rivencraft-" + secrets.token_hex(16)
chunks = []
for name, value in fields.items():
chunks.extend((
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n".encode(),
value.encode(), b"\r\n",
))
filename = jar.name.replace('"', "")
mime = mimetypes.guess_type(filename)[0] or "application/java-archive"
chunks.extend((
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{file_field}\"; filename=\"{filename}\"\r\nContent-Type: {mime}\r\n\r\n".encode(),
jar.read_bytes(), b"\r\n", f"--{boundary}--\r\n".encode(),
))
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
def release_metadata(args: argparse.Namespace) -> tuple[dict, dict]:
changelog = args.changelog.read_text(encoding="utf-8")
if not changelog.strip():
raise RuntimeError("Changelog is empty")
modrinth = {
"name": args.name,
"version_number": args.version,
"changelog": changelog,
"dependencies": [],
"game_versions": [args.minecraft],
"version_type": args.channel,
"loaders": ["neoforge"],
"featured": False,
"status": "listed",
"project_id": MODRINTH_PROJECT,
"file_parts": ["file"],
"primary_file": "file",
"environment": "client_and_server",
}
curseforge = {
"changelog": changelog,
"changelogType": "markdown",
"displayName": args.name,
"gameVersionNames": [args.minecraft, "NeoForge", "Client", "Server", f"Java {args.java}"],
"releaseType": args.channel,
"isMarkedForManualRelease": False,
}
return modrinth, curseforge
def publish_modrinth(metadata: dict, jar: Path) -> None:
token = token_for("modrinth")
versions = request_json(
f"https://api.modrinth.com/v2/project/{MODRINTH_PROJECT}/version",
token=token,
)
if any(version.get("version_number") == metadata["version_number"] for version in versions):
raise RuntimeError(f"Modrinth version {metadata['version_number']} already exists")
body, content_type = multipart({"data": json.dumps(metadata)}, jar)
result = request_json(
"https://api.modrinth.com/v2/version", token=token, payload=body,
content_type=content_type, method="POST",
)
print(f"Modrinth: https://modrinth.com/mod/rivencraft/version/{result['version_number']}")
def publish_curseforge(metadata: dict, jar: Path) -> None:
token = token_for("curseforge")
body, content_type = multipart({"metadata": json.dumps(metadata)}, jar)
result = request_json(
f"https://www.curseforge.com/api/projects/{CURSEFORGE_PROJECT}/upload-file",
token=token, token_header="X-Api-Token", payload=body,
content_type=content_type, method="POST",
)
print(f"CurseForge: https://www.curseforge.com/minecraft/mc-mods/rivencraft/files/{result['id']} (pending approval)")
def publish_gitea(args: argparse.Namespace) -> None:
base_version, suffix = args.version.split("-", 1)
if suffix != f"{args.channel}.1":
raise RuntimeError("Gitea tag convention requires a version ending in the selected channel and .1")
tag = f"V{base_version}-mc{args.minecraft}-{suffix}"
# The release must refer to a tag already pushed by the normal Git workflow.
remote_tag = subprocess.run(
["git", "ls-remote", "origin", f"refs/tags/{tag}"],
text=True, capture_output=True, check=True,
)
if not remote_tag.stdout.strip():
raise RuntimeError(f"Push the Gitea tag {tag} before publishing")
token = f"token {token_for('gitea')}"
release_url = f"{GITEA_API}/releases/tags/{tag}"
release = request_json(release_url, token=token, not_found_ok=True)
notes = args.changelog.read_text(encoding="utf-8")
title = f"Rivencraft {args.minecraft} {args.channel} {args.version}"
if release is None:
metadata = {
"tag_name": tag,
"target_commitish": "main",
"name": title,
"body": notes,
"draft": False,
"prerelease": args.channel != "release",
}
release = request_json(
f"{GITEA_API}/releases", token=token,
payload=json.dumps(metadata).encode(), content_type="application/json", method="POST",
)
elif release.get("name") != title or release.get("body", "").strip() != notes.strip():
raise RuntimeError(f"Gitea release {tag} exists with different notes or title; inspect it before retrying")
assets = release.get("assets", [])
if any(asset.get("name") == args.jar.name for asset in assets):
raise RuntimeError(f"Gitea release {tag} already has {args.jar.name}")
body, content_type = multipart({}, args.jar, file_field="attachment")
attachment = request_json(
f"{GITEA_API}/releases/{release['id']}/assets", token=token,
payload=body, content_type=content_type, method="POST",
)
if attachment.get("name") != args.jar.name or attachment.get("size") != args.jar.stat().st_size:
raise RuntimeError("Gitea returned unexpected attachment metadata; inspect the release")
print(f"Gitea: {release['html_url']}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
store = sub.add_parser("store-token", help="Securely save a token in the system keyring")
store.add_argument("site", choices=SITES)
check = sub.add_parser("check-token", help="Verify stored token with a read-only API call")
check.add_argument("site", choices=SITES)
publish = sub.add_parser("release", help="Preview or publish a JAR release")
publish.add_argument("--jar", type=Path, required=True)
publish.add_argument("--changelog", type=Path, required=True)
publish.add_argument("--version", required=True)
publish.add_argument("--name", required=True)
publish.add_argument("--minecraft", required=True)
publish.add_argument("--java", required=True)
publish.add_argument("--channel", choices=("alpha", "beta", "release"), required=True)
publish.add_argument("--site", choices=(*SITES, "both", "all"), default="all")
publish.add_argument("--publish", action="store_true", help="Actually upload; omitted by default")
args = parser.parse_args()
if args.command == "store-token":
store_token(args.site)
return
if args.command == "check-token":
check_token(args.site)
return
if not args.jar.is_file() or args.jar.suffix != ".jar":
raise RuntimeError(f"JAR does not exist: {args.jar}")
if not args.changelog.is_file():
raise RuntimeError(f"Changelog does not exist: {args.changelog}")
if args.minecraft != "26.3":
raise RuntimeError("Only the 26.3 line is configured; 1.21.1 needs required dependency relations")
if args.jar.name != f"rivencraft-{args.minecraft}-{args.version}.jar":
raise RuntimeError("JAR filename does not match Minecraft and release versions")
modrinth, curseforge = release_metadata(args)
sites = SITES if args.site == "all" else (("modrinth", "curseforge") if args.site == "both" else (args.site,))
print(f"JAR: {args.jar.resolve()} ({args.jar.stat().st_size} bytes)")
print(f"Release: {args.version} | {args.channel} | Minecraft {args.minecraft} | NeoForge | Java {args.java}")
print(f"Destinations: {', '.join(sites)}")
if not args.publish:
print("PREVIEW ONLY: add --publish to upload")
return
if "gitea" in sites:
publish_gitea(args)
if "modrinth" in sites:
publish_modrinth(modrinth, args.jar)
if "curseforge" in sites:
publish_curseforge(curseforge, args.jar)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error:
print(f"Publishing failed: {error}", file=sys.stderr)
sys.exit(1)
+58 -1
View File
@@ -69,6 +69,12 @@ start_server first
send_and_wait 'rivencraft_port compat' 'Registered biomes by namespace:'
send_and_wait 'rivencraft_port age create smoke_alpha 123456789' 'Created rivencraft:age/smoke_alpha with seed 123456789'
send_and_wait 'rivencraft_port age create smoke_beta 987654321' 'Created rivencraft:age/smoke_beta with seed 987654321'
send_and_wait 'rivencraft_port age create smoke_flat 24680 flat' 'Created rivencraft:age/smoke_flat with seed 24680 terrain=FLAT'
send_and_wait 'rivencraft_port age create smoke_void 13579 void' 'Created rivencraft:age/smoke_void with seed 13579 terrain=VOID'
send_and_wait 'rivencraft_port age create smoke_cave 112233 cave' 'Created rivencraft:age/smoke_cave with seed 112233 terrain=CAVE'
send_and_wait 'rivencraft_port age create_multi smoke_flat_multi 24681 flat' 'Created rivencraft:age/smoke_flat_multi with seed 24681 terrain=FLAT'
send_and_wait 'rivencraft_port age create_multi smoke_void_multi 13580 void' 'Created rivencraft:age/smoke_void_multi with seed 13580 terrain=VOID'
send_and_wait 'rivencraft_port age create_multi smoke_cave_multi 112234 cave' 'Created rivencraft:age/smoke_cave_multi with seed 112234 terrain=CAVE'
# The index must reach disk while the server is still running. A graceful
# shutdown would hide the interrupted-save regression this checks.
index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/runtime_ages.dat"
@@ -78,26 +84,77 @@ index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/run
}
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_alpha'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_beta'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_flat'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_void'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_cave'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_flat_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_void_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_cave_multi'
send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat' 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void' 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave' 'rivencraft:age/smoke_cave seed=112233 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat_multi' 'rivencraft:age/smoke_flat_multi seed=24681 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void_multi' 'rivencraft:age/smoke_void_multi seed=13580 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave_multi' 'rivencraft:age/smoke_cave_multi seed=112234 chunkOffset=0 terrain='
send_and_wait 'execute in rivencraft:age/smoke_flat run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_flat to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_void run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_void to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_flat_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_flat_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_void_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_void_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_cave run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_cave to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_cave_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_cave_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_flat if block 0 0 0 minecraft:bedrock run say Flat bedrock starts at Y0' 'Flat bedrock starts at Y0'
send_and_wait 'execute in rivencraft:age/smoke_flat if block 0 3 0 minecraft:grass_block run say Flat surface is Y3' 'Flat surface is Y3'
send_and_wait 'execute in rivencraft:age/smoke_void if block 0 0 0 minecraft:air run say Void remains empty' 'Void remains empty'
send_and_wait 'execute in rivencraft:age/smoke_flat_multi if block 0 0 0 minecraft:bedrock run say Multi Flat bedrock starts at Y0' 'Multi Flat bedrock starts at Y0'
send_and_wait 'execute in rivencraft:age/smoke_void_multi if block 0 0 0 minecraft:air run say Multi Void remains empty' 'Multi Void remains empty'
# Check region interiors after loading the sampled chunks.
send_and_wait 'rivencraft_port age biome smoke_flat_multi 128 128' 'rivencraft:age/smoke_flat_multi biome at 128,128=minecraft:plains'
send_and_wait 'rivencraft_port age biome smoke_flat_multi 384 128' 'rivencraft:age/smoke_flat_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_void_multi 384 128' 'rivencraft:age/smoke_void_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_cave_multi 128 128' 'rivencraft:age/smoke_cave_multi biome at 128,128=minecraft:lush_caves'
send_and_wait 'rivencraft_port age biome smoke_cave_multi 384 128' 'rivencraft:age/smoke_cave_multi biome at 384,128=minecraft:dripstone_caves'
alpha_before=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
beta_before=$(grep -F 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
flat_before=$(grep -F 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
void_before=$(grep -F 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
if [[ "$alpha_before" == "$beta_before" ]]; then
printf 'Different Age seeds produced the same terrain probe: %s\n' "$alpha_before" >&2
exit 1
fi
if [[ "$flat_before" == "$void_before" ]]; then
printf 'Flat and Void Ages produced the same height probe: %s\n' "$flat_before" >&2
exit 1
fi
stop_server
start_server restart
send_and_wait 'rivencraft_port age list' 'smoke_alpha'
send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat' 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void' 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave' 'rivencraft:age/smoke_cave seed=112233 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat_multi' 'rivencraft:age/smoke_flat_multi seed=24681 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void_multi' 'rivencraft:age/smoke_void_multi seed=13580 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave_multi' 'rivencraft:age/smoke_cave_multi seed=112234 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age biome smoke_flat_multi 384 128' 'rivencraft:age/smoke_flat_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_void_multi 384 128' 'rivencraft:age/smoke_void_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_cave_multi 384 128' 'rivencraft:age/smoke_cave_multi biome at 384,128=minecraft:dripstone_caves'
alpha_after=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
beta_after=$(grep -F 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
flat_after=$(grep -F 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
void_after=$(grep -F 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
[[ "$alpha_before" == "$alpha_after" && "$beta_before" == "$beta_after" ]] || {
printf 'Age terrain changed across restart: %s/%s -> %s/%s\n' \
"$alpha_before" "$beta_before" "$alpha_after" "$beta_after" >&2
exit 1
}
[[ "$flat_before" == "$flat_after" && "$void_before" == "$void_after" ]] || {
printf 'Flat/Void terrain changed across restart: %s/%s -> %s/%s\n' \
"$flat_before" "$void_before" "$flat_after" "$void_after" >&2
exit 1
}
stop_server
printf '26.3 dedicated-server smoke passed: two distinct Ages persisted across restart.\n'
printf '26.3 dedicated-server smoke passed: Standard, Flat, Void and Cave Ages persisted across restart.\n'