Compare commits

..
Author SHA1 Message Date
dnorberg fa294be685 Port Flat and Void Age terrain to Minecraft 26.3 beta
Build / build (push) Canceled after 0s
2026-10-31 20:35:08 -04:00
dnorberg cb31156584 Add Modrinth and CurseForge API publishing helper
Build / build (push) Canceled after 0s
2026-10-31 02:53:32 -04:00
dnorberg c23b057f51 Advance Minecraft 26.3 port to authored Age beta
Build / build (push) Canceled after 0s
2026-10-31 02:38:22 -04:00
dnorberg 9ab8d3de4a Port Binder and Ink Mixer interactions to 26.3 alpha
Build / build (push) Canceled after 0s
2026-10-30 15:55:43 -04:00
dnorberg 56005ac512 Port crystal growth, pages, ink vials and link crafting to 26.3
Build / build (push) Canceled after 0s
2026-10-29 15:07:46 -04:00
dnorberg 9ff931afa9 Add 26.3 descriptive book binding and travel
Build / build (push) Canceled after 0s
2026-10-29 14:32:50 -04:00
dnorberg eda19de43c Prepare Minecraft 26.3 technical alpha 1.0.1.2-alpha.1
Build / build (push) Canceled after 0s
2026-10-29 14:25:39 -04:00
dnorberg 0fb2f7c1bb Add incomplete Minecraft 26.3 port prototype
Build / build (push) Canceled after 0s
2026-10-27 19:09:19 -04:00
112 changed files with 6281 additions and 0 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

+42
View File
@@ -0,0 +1,42 @@
# Rivencraft 1.0.1.2-alpha.1 — Minecraft 26.3
Experimental **NeoForge 26.3** port preview. This is a separate artifact from
the supported Minecraft 1.21.1 release, and it does **not** have feature
parity. Use a disposable test world or make a backup first. Do not migrate a
1.21.1 Rivencraft world to this alpha.
## Working in this alpha
- Rivencraft loads on Minecraft 26.3 / NeoForge 26.3.0.99 with Java 25.
- The first-party runtime Age backend creates separately seeded, persistent
prototype dimensions. The operator-only `/rivencraft_port age` commands can
create and inspect them.
- Runtime Age catalogs are synchronized and acknowledged before cross-dimension
travel. A missing or unacknowledged destination is refused.
- Operator-created Descriptive Books can enter prototype Ages.
- Handheld Linking Books can be named and bound to a location, then used from
their book GUI to return there. The destination data survives a restart.
- The Crystal Block and Crystal Shard are registered with reused art.
## Not yet ported
Normal authored Age generation, book crafting and workstations, Page symbols,
most blocks and items, portal systems, Link modifiers, the rotating destination
preview, instability, and optional mod integrations. The Linking Book GUI shows
a placeholder destination panel rather than a live scene. Generated Ages use
prototype vanilla terrain, not the player's authored Pages.
The [26.3 port status](MINECRAFT_26_3_PORT.md) and [open port issues](https://git.paddedcell.org/dnorberg/rivencraft/issues)
track the remaining work. The stable 1.21.1 release remains available
separately. Please file alpha bugs with the exact Minecraft, NeoForge and
Rivencraft versions, plus the relevant client/server log.
## Verification
- Clean 26.3 Gradle build passed.
- Disposable dedicated-server smoke test passed: two distinct Age seeds and
their terrain persisted across a restart.
- The user verified the handheld Linking Book GUI click and travel in-game on
2026-10-27.
These checks are **not** the full port acceptance matrix or a stability claim.
+37
View File
@@ -0,0 +1,37 @@
# Rivencraft 1.0.1.3-alpha.1 — Minecraft 26.3
Experimental **NeoForge 26.3** port preview. This is separate from the
supported Minecraft 1.21.1 release and does **not** have feature parity. Use
a disposable test world or make a backup first. Do not migrate a 1.21.1
Rivencraft world to this alpha.
## Changes since 1.0.1.2-alpha.1
- An unbound Descriptive Book now opens a naming screen. Binding it creates a
distinct prototype Age with a random seed; duplicate names receive a
numbered suffix.
- A bound Descriptive Book opens a book screen whose destination panel can be
clicked to enter its Age. The server validates the held book and waits for
the client's Age catalog acknowledgement before travel.
- Retired Age IDs are excluded when assigning a new book's Age name, so an
archived world cannot be silently reused.
## Still experimental
These Ages use prototype vanilla terrain. Page-authored generation, crafting
and workstations, most items and blocks, portals, Link modifiers, instability,
live destination previews, and optional mod integrations are not ported. The
book panel is a placeholder, not a rendered destination view. The
[port status](MINECRAFT_26_3_PORT.md) and [open port issues](https://git.paddedcell.org/dnorberg/rivencraft/issues)
track the remaining work. The stable 1.21.1 release remains available
separately.
## Verification
- Clean 26.3 Gradle build passed.
- Disposable dedicated-server smoke test passed: two distinct Age seeds and
their terrain persisted across restart.
- The player verified Descriptive Book binding and travel in game on
2026-10-29.
These checks are **not** the full port acceptance matrix or a stability claim.
+505
View File
@@ -0,0 +1,505 @@
# Minecraft 26.3 port status (issue #96)
Checked 2026-10-26. Minecraft Java 26.3 is the newest stable Java release with
NeoForge support. This is an engineering audit and the start of the port, **not
a claim that a 26.3 Rivencraft JAR is ready**. The 1.21.1 build remains the
release target until a separate 26.3 client and dedicated-server build passes.
The user wants both Minecraft lines maintained after the port, not a one-time
26.3 release. Gitea milestone "Dual-version support: 1.21.1 and 26.3"
tracks issues #97–#103 under parent #96.
## Version boundary
Minecraft 26.3/NeoForge uses Java 25, while Rivencraft 1.21.1 uses Java 21.
Build and publish separate artifacts; do not widen the Minecraft dependency
range of the 1.21.1 JAR. The official [26.3 release notes](https://www.minecraft.net/en-us/article/minecraft-java-edition-26-3),
[NeoForge 26.3 Maven metadata](https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml),
[NeoForge Java 25 setting](https://github.com/neoforged/NeoForge/blob/26.3.x/gradle.properties),
and [NeoForge migration primers](https://docs.neoforged.net/primer/docs/)
are the version sources. NeoForge 26.3.0.99 is the newest listed stable build
at the time of this check.
## Dependency availability
The entries below mean that a NeoForge 26.3 version is advertised by the
project, not that its API or runtime behavior has been validated with
Rivencraft. Recheck immediately before choosing versions for a port build.
| Integration | Role in 1.21.1 | 26.3 availability | Port decision |
| --- | --- | --- | --- |
| [Dynamic Dimensions AR Edition](https://modrinth.com/mod/dynamic-dimensions-ar-edition) | Required runtime Age loading | No; 1.21.1 only | Keep it for 1.21.1. Use a Rivencraft-owned runtime-dimension backend for 26.3. |
| [bad packets](https://modrinth.com/mod/badpackets) | Declared runtime dependency; no direct Java imports in Rivencraft | No advertised 26.3 version | Determine whether the new dimension backend needs it; otherwise remove it from the 26.3 artifact. Use NeoForge's own networking for Rivencraft packets. |
| [JEI](https://modrinth.com/mod/jei) | Optional recipe/page guides | Yes | Port and test its plugin against the 26.3 API. |
| [TerraBlender](https://modrinth.com/mod/terrablender) | Optional modded biome support | Yes | Port and verify biome discovery and Age generation. |
| [Serene Seasons](https://modrinth.com/mod/serene-seasons) and [GlitchCore](https://modrinth.com/mod/glitchcore) | Optional seasons | Yes | Port and repeat the per-Age seasonal matrix. |
| [Biomes O' Plenty](https://modrinth.com/mod/biomes-o-plenty) | Optional TerraBlender test addon | Yes | Test modded biome registration and decoration. |
| [Ruins (Structure Spawning System)](https://www.curseforge.com/minecraft/mc-mods/ruins-structure-spawning-system) | Optional authored ruins integration | Yes | Port and verify its generation hooks. |
| [Create](https://modrinth.com/mod/create) / bundled Ponder | Optional scene renderer | No advertised 26.3 version | Use Rivencraft's existing legacy-animation preview fallback for this target until a compatible release exists. |
| [Immersive Portals for (Neo)Forge](https://modrinth.com/mod/immersive-portals-neoforge) | Optional live views and portal teleport integration | No advertised 26.3 version | Use the fallback portal surface and normal Rivencraft linking. Do not claim live destination views on 26.3. |
Ecliptic Seasons publishes 26.3, but Rivencraft's Ecliptic integration was
deferred and is not a replacement for the current Serene integration.
The alternatives checked for runtime Age loading do not meet the 26.3
NeoForge requirement: [upstream Dynamic Dimensions](https://github.com/TeamGalacticraft/DynamicDimensions)
and its [AR fork](https://github.com/dercodeKoenig/DynamicDimensions)
have no published 26.3 branch or artifact;
[DimLib](https://modrinth.com/mod/dimlib) is Fabric-only and stops at 1.21.1;
and [DimensionsCore](https://www.curseforge.com/minecraft/mc-mods/dimensionscore-dynamic-dimension-world-management)
currently publishes only a Forge 1.20.1 file. Mods that add fixed dimensions
or portals do not supply the arbitrary per-book runtime worlds Rivencraft
needs. This is a search result, not proof that no other project exists.
## 26.3 backend design
The 26.3 port is a **fresh-save target**. It does not need to import 1.21.1
Age worlds or migrate Dynamic Dimensions' saved registry data. The 1.21.1
artifact will continue using that library. Within 26.3, however, a created
Age must retain its identity, seed, chunks and access across save/restart.
1. Keep the existing `AgeDimensionService`/`AgeDimensionBackend` boundary.
Implement a target-specific 26.3 backend without importing Dynamic
Dimensions or its seed mixin. The backend contract needs registration at
server start, create/load, unload and delete. Return a failure without
publishing a half-created Age if any step fails.
2. At server startup, reconstruct all non-retired Ages from Rivencraft's
`AgeRegistrySavedData` **before** players can join. For a newly bound book,
register its unique `rivencraft:age/...` dimension and construct a
`ServerLevel` from its authored chunk generator, dimension type and saved
Age seed. The seed must feed chunk random state, not only `getSeed()`.
3. Register the level with the server's level map and the appropriate
dimension registries, then synchronize the new dimension definition to
already-connected clients before sending a travel packet. Handle a
connecting client receiving every existing Age. Determine the minimal
26.3 registry and network hooks from the current NeoForge source; do not
assume `/reload` creates new running levels.
4. Initialize world border, view/simulation distance, forced chunks, spawn
settings, per-Age data storage and tick/save lifecycle as a normal level.
On unload or retirement, refuse while players occupy the Age, move any
remaining entities safely, flush pending chunk writes, detach the level,
and synchronize removal. Retirement must not delete another Age's data.
5. Review the MIT-licensed Dynamic Dimensions code as an implementation
reference. If any code is adapted, retain its copyright/license notice and
identify the derived files. Prefer the smallest backend Rivencraft needs
over a general-purpose library fork.
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
future chunks, unload/reload, retire one Age without harming the other, and
verify normal travel without optional mods. Then repeat relevant paths with
available integrations. A failed synchronization must be reported and leave
the player in their current level, not crash or strand them.
## Implementation order
1. Keep `AgeDimensionService` independent of a concrete runtime-dimension
implementation. The 1.21.1 Dynamic Dimensions calls now live in
`AgeDimensionBackend`; the `AgeDimensionSeedMixin` is 1.21.1-only.
2. Create a separate 26.3/Java 25 build target. Replace the dimension backend,
then migrate the main sources through the official NeoForge primers. Do not
publish a partial JAR with missing Age generation.
3. Port optional integration classes one at a time. Keep unavailable mods
optional and preserve their existing fallbacks. Upgrade JEI, TerraBlender,
Serene Seasons/GlitchCore, Biomes O' Plenty, and Ruins only in the 26.3
target; do not change the validated 1.21.1 dependency set.
4. Run compile, data/resource validation, new-world Age generation, Age
save/restart/reload, direct and portal/book travel, client rendering, and
dedicated-server tests on both targets. Test without optional mods, then
with each available integration. Require an in-game check before release.
The core blocker is **not** a version number change: 26.3 makes a major API
jump and the required 1.21.1 runtime-dimension library has no 26.3 build.
## Build isolation started in #97
The repository root remains the playable 1.21.1/Java 21 build. A separate
`ports/mc263` Gradle build now resolves NeoForge 26.3.0.99 and compiles with
Java 25. The build workflow has separate Java 21 and Java 25 jobs. Its JAR
task remains disabled; this does not satisfy #97's final artifact criteria.
## First core slice in #98
The 26.3 target now has real Rivencraft metadata and an entrypoint. It
registers the Crystal Shard and Crystal Block with their reused 1.21.1
textures/models plus the 26.3 item-model definitions. `build` passed, and a
dedicated development server loaded the mod and reached `Done` with a new
world and no optional mods. The server was then stopped. This validates only
the entrypoint/registry/resource foundation. A development client also loaded
Rivencraft and reloaded its resources without reported missing models or
textures. The client logged the host's missing `libflite.so` narrator library;
this is unrelated to Rivencraft. Both test processes were stopped after
startup. This does **not** validate in-world client rendering or Age
functionality. Age generators, data, networking, and client code still
need migration; #98 remains open.
## Runtime-level prototype in #99
The 26.3 target has a first-party server-side runtime-level loader and a
SavedData seed index. It constructs each level with a `LevelStem` seed override,
publishes the level only after construction, and restores indexed Ages when
the Overworld loads. A dedicated-server probe created two separate Ages with
seeds `123456789` and `987654321`; after generating chunks, their terrain
fingerprints differed (`2865094` and `3227421`). Both levels, their region
files, seeds, and fingerprints survived a server restart, and the startup log
shows restoration before `Done`. The temporary operator-only
`rivencraft_port age` command exists solely to test this non-distributable
port workspace.
This is **not** issue #99 completion. The loader currently reuses the vanilla
Overworld generator because issue #98 has not ported authored generators or
Age definitions. The synchronized operator travel probe below does not port
normal book travel. The 26.3 JAR task remains disabled.
## Runtime client-catalog prototype in #100
The 26.3 target now sends the current dimension-key catalog to each joining
client and broadcasts a new catalog when a prototype Age is created or
retired. Clients update their known-level set and acknowledge a version; the
server's `rivencraft_port age enter <name>` probe refuses travel until that
acknowledgement arrives and the target is still loaded. A stale acknowledgement
does not satisfy the gate. This follows NeoForge's play-payload registration
and client-handler separation. It is not a general dynamic-registry sync for
authored dimension types, which have not been ported.
The dedicated-server probe used two simultaneous 26.3 clients. Both
acknowledged a newly created Age without reconnecting; a second client entered
an existing runtime Age; a player saved inside an Age reconnected after server
restart and remained there. The operator travel probe searches near origin for
safe footing and creates a small Crystal Block platform if none is found.
This probe is not the final book landing implementation.
`rivencraft_port age retire <name>` refuses to retire an Age containing a
player, then saves and closes it, removes it from the active index, and
broadcasts the reduced catalog. On the dedicated test server, the client
acknowledged creation and retirement even when they happened back-to-back; a
late acknowledgement for the intermediate catalog was ignored. Travel to the
retired Age was refused without moving the player, and retiring an occupied
Age was refused. The #101 lifecycle slice below now archives exact Age data.
This remains a **prototype**, not completion of #100: normal Linking and
Descriptive Book travel, authored dimension-type/generator registration, full
client registry synchronization, and an integrated-client regression pass
must follow the core port. The 26.3 JAR remains disabled. The local test
server was stopped after verification.
## Runtime lifecycle prototype in #101
The operator-only backend probe now supports `age unload`, `reload`, `reseed`,
and `retire` in addition to `create`, `list`, `sample`, and `enter`. Unload and
retirement refuse a level containing players. A full chunk/entity save and
close handles other entities; Minecraft's shutdown-style ticket deactivation
and startup-style activation preserve forced chunks across runtime reloads.
The loaded-dimension catalog is rebroadcast after unload/reload/reseed and
retirement, with stale acknowledgements ignored.
On the dedicated server, a generated Age unloaded and reloaded with the same
terrain fingerprint. Reseeding `lifetest` from `24680` to `13579` kept the
previously generated origin fingerprint `2898472`; previously ungenerated
chunks at offset 100 produced fingerprint `2897795`, identical to a fresh
`13579` control Age and different from a fresh `24680` control (`2863099`).
After restart, `lifetest` still reported seed `13579` and its original origin
fingerprint. Three consecutive unload/reload cycles with a connected client
completed; that client acknowledged the final catalog and intermediate stale
acknowledgements were ignored. Unloading the player's current Age was refused.
A force-loaded chunk and a summoned armor stand with UUID
`[I; -872307724, 883641011, -1239011659, 796091120]` survived repeated
runtime unload/reload and server restart. The entity could be queried again
after its chunk finished loading. The probe retired a different Age,
`seedcontrol`: only its directory moved to the sibling `world-retired-ages`
archive, other Age directories stayed in place, and reloading/recreating the
retired ID was refused after restart. A second archival probe exercised the
tombstone-before-move sequence and again moved only its own directory. The
tombstone is persisted before the archive move so an interrupted archival
cannot silently revive an old Age; an ordinary move failure attempts to
restore the index and level. Ten more consecutive unload/reload cycles
completed on the dedicated server, with its open file-descriptor count steady
at 218 before and after. That is a narrow smoke check, not proof of no leak.
This still does **not** close #101. The prototype has not been integrated with
normal books or the full 26.3 Age definition, nor has a long-running resource
leak test or interrupted-archive recovery drill been completed. Its dev world
and archive are under ignored `ports/mc263/build`, not a release save.
## Optional-integration probe in #102
On 2026-10-27, the published NeoForge 26.3 versions checked were JEI
31.17.0.95, TerraBlender 26.3.0.0.9, Biomes O' Plenty 26.3.0.0.15,
GlitchCore 26.3.0.0.5, and Serene Seasons 26.1.2.0.7 (its file is labelled
for Minecraft 26.3). Ruins advertises 26.3.1NF. Create/Ponder and
Immersive Portals still had no advertised compatible version. These are
availability checks, not API-compatibility claims.
The 26.3 development target now has the operator-only
`/rivencraft_port compat` command. It reports loaded optional mod IDs and
the count of registered biome keys by namespace, so integration smoke tests
can distinguish a missing addon from a missing biome registration. It does
not change Age generation. A clean dedicated server loaded with no optional
mods and reported 67 vanilla biomes. A separate dedicated-server launch with
TerraBlender, Biomes O' Plenty, GlitchCore, and Serene Seasons loaded all four;
the probe reported 68 `biomesoplenty` biome keys, and TerraBlender logged its
Overworld and Nether regions. Both launches stopped cleanly.
Issue #102 remains open. The 26.3 target still lacks authored Ages, JEI
guides, per-Age season policy, Ruins hooks, book previews and crystal portals,
so the relevant integration and fallback acceptance tests cannot yet pass.
The probe must not be mistaken for feature support. The existing 1.21.1
integration code and dependencies remain unchanged.
## First player-facing bridge in #104
The 26.3 port now registers a Descriptive Book with the Legacy item texture
and a persistent, network-synchronized Age-ID data component. The operator-only
`/rivencraft_port age book <name>` probe grants a book for an already indexed
Age; an unbound book does not create a world. Using the bound book calls the
same server-authoritative, catalog-acknowledged landing path as the existing
`age enter` probe. A missing or unloaded Age refuses travel. This avoids
trusting a book-supplied seed or creating a new Age from a forged item.
The dedicated-server smoke test still passed after this bridge, and a 26.3
development client loaded the new item model and texture without a missing
resource warning. The host still reports a missing Linux `libflite.so`
narrator library; this is not a Rivencraft item error. The client was stopped.
The player confirmed right-click travel in game. Book component persistence
across restart still needs a player test; the headless smoke test cannot
exercise it. To test the
bridge in a disposable 26.3 world as an operator:
1. `/rivencraft_port age create booktest 123456789`
2. Wait for the Age catalog acknowledgement, then
`/rivencraft_port age book booktest`.
3. Right-click the granted book and verify arrival in
`rivencraft:age/booktest`. Restart client and server, then use the same
book again and verify it targets the same saved Age.
This is deliberately a **port probe**, not the final book workflow: the Book
Binder, naming UI, GUI preview, automatic return link, dropped-book entity, authored Age
plan, and Legacy landing rules are not yet connected. The bound book currently
remains in hand after travel. Do not distribute this target or mark #104 done.
For a local player test only, the normally disabled port JAR can be built with
`./gradlew -p ports/mc263 build -PallowIncompletePortJar=true`; its filename
retains `26.3-INCOMPLETE` and a `port-dev` version marker. This does not enable the normal
release artifact or imply that the full mod has been ported.
The first Prism test exposed a production-only catalog bug: Minecraft 26.3's
`ClientPacketListener.levels()` returned an immutable set, and the client
handler attempted to clear it. That threw `UnsupportedOperationException`
before the client could acknowledge the Age, so book travel was refused. The
handler now replaces the entire level set through a narrow access transformer
and acknowledges only after replacement. The corrected local test JAR is
`rivencraft-26.3-INCOMPLETE-0.0.0.1-port-dev.jar`. It built successfully;
the player used that JAR to verify actual travel in game on 2026-10-27.
### Linking Book return-path slice
The separate 26.3 target now registers a Linking Book using the Legacy bound
book sprite. `/rivencraft_port link book` grants an operator-only probe book
bound to the player's exact current dimension, coordinates, and facing. Its
destination is a persistent data component resolved by the server on use. A
missing dimension, unacknowledged catalog, same-dimension target without
Intra-Linking, out-of-bounds target, or obstructed arrival is rejected without
moving the player. This path does **not** generate a landing platform.
For a test, stand at an unobstructed spot in the Overworld and run
`/rivencraft_port link book`. Create and bind an Age Descriptive Book using the
steps above, travel into that Age, then right-click the Linking Book. It
should return to the exact bound spot and facing. Same-dimension use should
refuse. Repeat after a server restart to check component persistence. This
slice is `0.0.0.2-port-dev` and remains explicitly incomplete: normal
crafting/binding, modifiers, book GUI, origin-drop entity, and safe fallback
are still unported. The probe book remains in inventory after travel.
### Age-index durability after the first in-game return-link check
The first return-link test exposed a recoverable prototype failure: Seamless
Portals rejected a newly added runtime Age, the integrated server exited
abnormally, and a second Age created just before the exit was not present in
Rivencraft's saved index on restart. Its directory contained no saved chunks.
The `0.0.0.3-port-dev` slice flushes the index synchronously before announcing
newly created Ages to clients. It also flushes reseed changes. The dedicated
server smoke test now checks that both new Age IDs are already in the on-disk
index **before** it issues `stop`, then verifies both seeds/terrain after a
restart. This closes the ordinary interrupted-shutdown gap but is not a
substitute for a power-loss durability test or integration with other mods'
dynamic-dimension catalogs.
### Handheld Linking Book binding slice
The `0.0.0.4-port-dev` target adds a client-only book screen using the Legacy
book artwork. An unbound Linking Book accepts a player-chosen title; a
serverbound packet binds the held book to the player's *current* dimension,
coordinates and facing only after checking the book, title length and empty
target. A bound book opens the same screen and uses a server-validated
destination-panel click for travel. This replaces the earlier direct item-use
probe. The panel is a visible placeholder, **not** a live snapshot. The
rotating preview, completed book behavior (including origin drop), recipe,
modifiers, stand handling and other port systems remain unported.
To test this slice, use `/give @s rivencraft:linking_book` in a 26.3 test
world, stand at the intended return point, right-click the unbound book, enter
a name and bind. Travel to an Age, right-click the book again and click its
right-hand destination panel. Verify the named book persists across restart,
same-dimension travel is refused, and a replaced/removed held book cannot be
bound or used by a delayed screen packet. Do not treat this partial target as
a playable release.
The first `0.0.0.4-port-dev` in-game right-click exposed a 26.3 screen API
mistake: `Screen.extractRenderStateWithTooltipAndSubtitles` already calls
`extractBackground`, so the screen's second call raised `Can only blur once per
frame`. The `0.0.0.5-port-dev` fix removes that duplicate background call.
The next in-game check found that a bound Linking Book's right-hand panel did
not trigger travel: its target component was persistent but lacked a network
codec, so the client screen could not identify the book as bound. The
`0.0.0.6-port-dev` slice adds stream codecs for the Linking Book target and
Descriptive Book Age ID. Server-side held-item and destination checks remain
authoritative. This needs another in-game check before considering the slice
validated.
The `0.0.0.6-port-dev` retest showed the bound-book panel but left-clicking
did not even close the GUI. Minecraft 26.3's primary mouse button is
`InputConstants.MOUSE_BUTTON_LEFT` (value 1), while the port handler still
checked the 1.21.1 value 0. The `0.0.0.7-port-dev` slice uses the 26.3
constant. This still requires in-game verification of the full click/travel
path.
The player confirmed the `0.0.0.7-port-dev` bound Linking Book click/travel
path passes in game on 2026-10-27. The 26.3 target remains incomplete and is
not ready for a public release or closure of the parent port issue.
### Explicit technical alpha exception
At the player's request, `1.0.1.2-alpha.1` packages this limited 26.3 port as
an experimental alpha. It is **not** the full mod and does not pass the stable
release gate above. The alpha label and release notes must state that Ages use
prototype vanilla terrain, normal authoring and most content are absent, and
worlds should be disposable or backed up. The 1.21.1 stable release remains
independent; issues #96–#104 stay open until the full port and validation pass.
The next development slice, `1.0.1.3-alpha.1`, adds a basic Descriptive Book
screen and server-validated binding. Naming an unbound book creates a unique
prototype Age with a random seed; clicking the bound book's placeholder panel
uses the acknowledged-catalog travel gate. This is not authored Page binding
or a live destination preview. The player confirmed the Descriptive Book
binding and travel interaction in game on 2026-10-29.
The following `1.0.1.4-alpha.1` development iteration registers a small
Rivencraft creative tab for the two Books, Crystal Block, and Crystal Shard.
It builds and passes the dedicated-server Age smoke test. The player confirmed
the tab and four entries in game on 2026-10-29. No Page categories or
unported items are advertised in the 26.3 tab.
The next local `1.0.1.5-alpha.1` iteration selects Legacy's unwritten
Linking Book sprite until a target is bound, then the bound sprite. It also
restores the four-Crystal-Shard crafting recipe for a Crystal Block. Both
changes passed the player's in-game visual/crafting check on 2026-10-29.
The local `1.0.1.6-alpha.1` iteration ports Budding Crystal and the four
blue crystal growth stages, reusing the 1.21.1 sprites and vanilla amethyst
growth/harvest rules. Crystal clusters yield Shards, but natural crystal
formation generation remains unported. Test growth with a Creative or
command-placed Budding Crystal; the player passed the in-game check.
The local `1.0.1.7-alpha.1` iteration adds one persistent Page item with
unwritten and Link Panel variants. It reuses the existing Page/Link Panel
artwork and exposes only the Link Panel variant in its own creative tab,
matching the 1.21.1 separation of Link Pages from symbol pages. Blank Pages
remain command-only bases; the Ink Mixer, Book Binder and symbol writing are
not yet ported. The player passed the in-game visual and persistence check.
The local `1.0.1.8-alpha.1` iteration ports the Ink Vial item, its animated
sprite, and the black-dye/glass-bottle/water-bucket crafting recipe. Using
it with paper in the other hand prepares a Link Panel at the current 1.21.1
default cost of 2 ink from a 16-use vial. Ink remaining persists per vial;
an empty vial yields a glass bottle. The Ink Mixer, configurable ink cost,
and Book Binder remain unported. The player passed the in-game craft/use check.
The local `1.0.1.9-alpha.1` iteration ports the special shapeless leather +
Link Panel recipe for an unwritten Linking Book. The 26.3 recipe serializer
rejects blank Pages and extra ingredients and preserves a panel's custom name
without carrying its Page identity onto the Book. No Link Panel modifiers are
ported yet. The player passed the in-game crafting check on 2026-10-29. The
full Book Binder GUI and Age authoring remain the next larger port step.
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.
## Verification of this first slice
The 1.21.1 `compileJava` and `build` tasks passed offline after the backend
extraction. The GameTest server ran 93 tests; 92 passed, while
`twoblockdeskdropsonce` failed because breaking either desk half dropped zero
desks instead of one. That desk failure is outside the dimension-backend edit
and must be resolved or rechecked before a release. The 1.21.1 `build` task
passed again after the 26.3 registry slice.
+63
View File
@@ -0,0 +1,63 @@
# Publishing Rivencraft with the 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
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
```
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
```
## 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 helper uses 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.
+200
View File
@@ -0,0 +1,200 @@
# Minecraft 26.3 beta port workspace
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, or Void 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
sprite, and four Crystal Shards craft into a Crystal Block; both were also
verified in game. The current local iteration adds Budding Crystal and all
four blue crystal growth stages with vanilla-style harvesting. Natural crystal
formation generation is not yet ported; use Creative or commands to obtain a
Budding Crystal for this test. The player verified the growth chain in game.
The next local slice adds the Link Panel Page as a separate creative entry,
using the existing item texture. Blank Pages remain unavailable in Creative;
the player verified the Link Panel's appearance in game. Ink Vials now use
the existing crafting recipe and animated sprite. With paper in the other
hand, an Ink Vial prepares a Link Panel for two of its sixteen ink uses, as
in the current 1.21.1 default. The 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 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.
From the repository root:
```sh
./gradlew -p ports/mc263 build
```
This standalone Gradle build uses Minecraft 26.3, NeoForge 26.3.0.99,
ModDevGradle 2.0.148, and Java 25. The repository root remains the independent
Minecraft 1.21.1/Java 21 build:
```sh
./gradlew build
```
The 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
and properties. Set a nonconflicting `server-port` there before running if
another Minecraft server is active. The prototype restores all indexed Ages
during the Overworld load, before initial chunk preparation completes.
@@ -0,0 +1,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.
+196
View File
@@ -0,0 +1,196 @@
plugins {
id 'java-library'
id 'net.neoforged.moddev' version '2.0.148'
}
group = 'net.dnorberg.rivencraft'
version = mod_version
base {
archivesName = "rivencraft-${minecraft_version}"
}
java.toolchain.languageVersion = JavaLanguageVersion.of(25)
neoForge {
version = project.neo_version
runs {
client {
client()
if (project.hasProperty('connectAddress')) {
programArgument '--quickPlayMultiplayer'
programArgument project.property('connectAddress').toString()
}
if (project.hasProperty('clientName')) {
programArgument '--username'
programArgument project.property('clientName').toString()
gameDirectory = project.layout.buildDirectory.dir('run-client-' + project.property('clientName')).get().asFile
}
}
server {
server()
gameDirectory = project.hasProperty('serverRunDir')
? file(project.property('serverRunDir').toString())
: project.layout.buildDirectory.dir('run-server').get().asFile
programArgument '--nogui'
}
}
mods {
rivencraft {
sourceSet(sourceSets.main)
}
}
}
def generateModMetadata = tasks.register('generateModMetadata', ProcessResources) {
def replaceProperties = [
mod_id: mod_id,
mod_name: mod_name,
mod_license: mod_license,
mod_version: mod_version,
neo_version: neo_version,
minecraft_version_range: minecraft_version_range,
]
inputs.properties replaceProperties
expand replaceProperties
from 'src/main/templates'
into 'build/generated/sources/modMetadata'
}
sourceSets.main.resources.srcDir generateModMetadata
neoForge.ideSyncTask generateModMetadata
// Keep the bundled page list in step with the 1.21.1 symbol definitions.
abstract class GeneratePortSymbolIndex extends DefaultTask {
@InputDirectory abstract DirectoryProperty getSymbolsDir()
@InputDirectory abstract DirectoryProperty getPortSymbolsDir()
@OutputDirectory abstract DirectoryProperty getIndexDir()
@TaskAction void generate() {
File root = symbolsDir.get().asFile
def definitions = new LinkedHashMap<String, Object>()
java.nio.file.Files.walk(root.toPath()).withCloseable { paths ->
paths.filter { java.nio.file.Files.isRegularFile(it) && it.toString().endsWith('.json') }
.sorted().forEach { source ->
String path = root.toPath().relativize(source).toString()
.replace('\\', '/').replaceAll(/\.json$/, '')
definitions["rivencraft:${path}"] = new groovy.json.JsonSlurper().parse(source.toFile())
}
}
File portRoot = portSymbolsDir.get().asFile
java.nio.file.Files.walk(portRoot.toPath()).withCloseable { paths ->
paths.filter { java.nio.file.Files.isRegularFile(it) && it.toString().endsWith('.json') }
.sorted().forEach { source ->
String path = portRoot.toPath().relativize(source).toString()
.replace('\\', '/').replaceAll(/\.json$/, '')
definitions["rivencraft:${path}"] = new groovy.json.JsonSlurper().parse(source.toFile())
}
}
File index = new File(indexDir.get().asFile, 'assets/rivencraft/creative_symbol_pages.json')
index.parentFile.mkdirs()
index.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(definitions)) + '\n'
}
}
def generatePortSymbolIndex = tasks.register('generatePortSymbolIndex', GeneratePortSymbolIndex) {
symbolsDir.set(layout.projectDirectory.dir('../../src/main/resources/data/rivencraft/rivencraft/symbols'))
portSymbolsDir.set(layout.projectDirectory.dir('src/main/resources/data/rivencraft/rivencraft/symbols'))
indexDir.set(layout.buildDirectory.dir('generated/resources/creativeSymbols'))
}
sourceSets.main.resources.srcDir generatePortSymbolIndex
// Use the established symbol names/descriptions without overwriting 26.3-only strings.
abstract class GeneratePortLanguage extends DefaultTask {
@InputFile abstract RegularFileProperty getBaseLanguage()
@InputFile abstract RegularFileProperty getPortLanguage()
@OutputDirectory abstract DirectoryProperty getOutputDir()
@TaskAction void generate() {
def base = new groovy.json.JsonSlurper().parse(baseLanguage.get().asFile) as Map
def port = new groovy.json.JsonSlurper().parse(portLanguage.get().asFile) as Map
base.putAll(port)
File result = new File(outputDir.get().asFile, 'assets/rivencraft/lang/en_us.json')
result.parentFile.mkdirs()
result.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(base)) + '\n'
}
}
def generatePortLanguage = tasks.register('generatePortLanguage', GeneratePortLanguage) {
baseLanguage.set(layout.projectDirectory.file('../../src/main/resources/assets/rivencraft/lang/en_us.json'))
portLanguage.set(layout.projectDirectory.file('port_en_us.json'))
outputDir.set(layout.buildDirectory.dir('generated/resources/portLanguage'))
}
sourceSets.main.resources.srcDir generatePortLanguage
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'
include 'assets/rivencraft/models/item/ink_vial.json'
include 'assets/rivencraft/models/item/crystal_shard.json'
include 'assets/rivencraft/textures/item/descriptive_book.png'
include 'assets/rivencraft/models/item/descriptive_book.json'
include 'assets/rivencraft/textures/item/linking_book.png'
include 'assets/rivencraft/textures/item/linking_book_unwritten.png'
include 'assets/rivencraft/models/item/linking_book_bound.json'
include 'assets/rivencraft/textures/gui/bookui_cover.png'
include 'assets/rivencraft/textures/gui/notebook_pages.png'
include 'assets/rivencraft/textures/gui/bookui_pager.png'
include 'assets/rivencraft/textures/block/crystal_block.png'
include 'assets/rivencraft/textures/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'
include 'assets/rivencraft/blockstates/small_crystal_bud.json'
include 'assets/rivencraft/blockstates/medium_crystal_bud.json'
include 'assets/rivencraft/blockstates/large_crystal_bud.json'
include 'assets/rivencraft/blockstates/crystal_cluster.json'
include 'assets/rivencraft/models/block/budding_crystal.json'
include 'assets/rivencraft/models/block/small_crystal_bud.json'
include 'assets/rivencraft/models/block/medium_crystal_bud.json'
include 'assets/rivencraft/models/block/large_crystal_bud.json'
include 'assets/rivencraft/models/block/crystal_cluster.json'
include 'assets/rivencraft/models/item/budding_crystal.json'
include 'assets/rivencraft/models/item/small_crystal_bud.json'
include 'assets/rivencraft/models/item/medium_crystal_bud.json'
include 'assets/rivencraft/models/item/large_crystal_bud.json'
include 'assets/rivencraft/models/item/crystal_cluster.json'
include 'assets/rivencraft/textures/block/small_crystal_bud.png'
include 'assets/rivencraft/textures/block/medium_crystal_bud.png'
include 'assets/rivencraft/textures/block/large_crystal_bud.png'
include 'assets/rivencraft/textures/block/crystal_cluster.png'
include 'data/rivencraft/loot_table/blocks/crystal_block.json'
include 'data/minecraft/tags/block/mineable/pickaxe.json'
include 'data/minecraft/tags/item/cluster_max_harvestables.json'
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
tasks.named('runServer').configure {
standardInput = System.in
}
+10
View File
@@ -0,0 +1,10 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.caching=true
minecraft_version=26.3
minecraft_version_range=[26.3]
neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.36-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."
}
+11
View File
@@ -0,0 +1,11 @@
pluginManagement {
repositories {
gradlePluginPortal()
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
rootProject.name = 'rivencraft-mc263-port'
@@ -0,0 +1,166 @@
package net.dnorberg.rivencraft;
import com.mojang.logging.LogUtils;
import com.mojang.serialization.Codec;
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.dnorberg.rivencraft.age.PortAgeCommands;
import net.dnorberg.rivencraft.age.AgeCatalogSync;
import net.dnorberg.rivencraft.block.BuddingCrystalBlock;
import net.dnorberg.rivencraft.block.PortBookBinderBlock;
import net.dnorberg.rivencraft.block.InkMixerBlock;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.registry.PortBiomeSources;
import net.dnorberg.rivencraft.registry.PortChunkGenerators;
import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.AmethystClusterBlock;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.neoforge.registries.DeferredBlock;
import net.neoforged.neoforge.registries.DeferredItem;
import net.neoforged.neoforge.registries.DeferredRegister;
import net.neoforged.neoforge.common.extensions.IMenuTypeExtension;
import java.util.function.Supplier;
import net.neoforged.neoforge.common.NeoForge;
import org.slf4j.Logger;
/** First 26.3 registry slice; gameplay remains in the 1.21.1 target for now. */
@Mod(Rivencraft.MOD_ID)
public final class Rivencraft {
public static final String MOD_ID = "rivencraft";
public static final Logger LOGGER = LogUtils.getLogger();
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID);
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
private static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITY_TYPES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, MOD_ID);
private static final DeferredRegister<MenuType<?>> MENUS =
DeferredRegister.create(Registries.MENU, MOD_ID);
private static final DeferredRegister<RecipeSerializer<?>> RECIPE_SERIALIZERS =
DeferredRegister.create(Registries.RECIPE_SERIALIZER, MOD_ID);
private static final DeferredRegister.DataComponents COMPONENTS =
DeferredRegister.createDataComponents(Registries.DATA_COMPONENT_TYPE, MOD_ID);
public static final Supplier<DataComponentType<Identifier>> AGE_BOOK_ID = COMPONENTS.registerComponentType(
"age_book_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
public static final Supplier<DataComponentType<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
/** A present component distinguishes Link Panels from unwritten base Pages. */
public static final Supplier<DataComponentType<Boolean>> LINK_PANEL_PAGE = COMPONENTS.registerComponentType(
"link_panel_page", builder -> builder.persistent(Codec.BOOL)
.networkSynchronized(ByteBufCodecs.BOOL));
public static final Supplier<DataComponentType<Identifier>> SYMBOL_PAGE_ID = COMPONENTS.registerComponentType(
"symbol_page_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
/** Ordered authored page IDs remain on the finished book for later port slices. */
public static final Supplier<DataComponentType<java.util.List<Identifier>>> BOOK_SYMBOLS = COMPONENTS.registerComponentType(
"book_symbols", builder -> builder.persistent(Identifier.CODEC.listOf())
.networkSynchronized(ByteBufCodecs.<io.netty.buffer.ByteBuf, Identifier>list(252)
.apply(Identifier.STREAM_CODEC)));
/** Set only on temporary GUI stacks; NeoForge 26.3 requires a registered codec. */
public static final Supplier<DataComponentType<Boolean>> PAGE_GLYPH_ONLY = COMPONENTS.registerComponentType(
"page_glyph_only", builder -> builder.persistent(Codec.BOOL)
.networkSynchronized(ByteBufCodecs.BOOL));
public static final Supplier<DataComponentType<Integer>> INK_REMAINING = COMPONENTS.registerComponentType(
"ink_remaining", builder -> builder.persistent(Codec.INT)
.networkSynchronized(ByteBufCodecs.VAR_INT));
public static final Supplier<DataComponentType<LinkProperties>> LINK_PROPERTIES = COMPONENTS.registerComponentType(
"link_properties", builder -> builder.persistent(LinkProperties.CODEC)
.networkSynchronized(LinkProperties.STREAM_CODEC));
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
public static final DeferredBlock<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,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.BUDDING_AMETHYST).mapColor(MapColor.COLOR_BLUE));
public static final DeferredItem<BlockItem> BUDDING_CRYSTAL_ITEM = ITEMS.registerSimpleBlockItem(BUDDING_CRYSTAL);
public static final DeferredBlock<AmethystClusterBlock> SMALL_CRYSTAL_BUD = BLOCKS.registerBlock(
"small_crystal_bud", properties -> new AmethystClusterBlock(3.0F, 4.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.SMALL_AMETHYST_BUD));
public static final DeferredItem<BlockItem> SMALL_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(SMALL_CRYSTAL_BUD);
public static final DeferredBlock<AmethystClusterBlock> MEDIUM_CRYSTAL_BUD = BLOCKS.registerBlock(
"medium_crystal_bud", properties -> new AmethystClusterBlock(4.0F, 3.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.MEDIUM_AMETHYST_BUD));
public static final DeferredItem<BlockItem> MEDIUM_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(MEDIUM_CRYSTAL_BUD);
public static final DeferredBlock<AmethystClusterBlock> LARGE_CRYSTAL_BUD = BLOCKS.registerBlock(
"large_crystal_bud", properties -> new AmethystClusterBlock(5.0F, 3.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.LARGE_AMETHYST_BUD));
public static final DeferredItem<BlockItem> LARGE_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(LARGE_CRYSTAL_BUD);
public static final DeferredBlock<AmethystClusterBlock> CRYSTAL_CLUSTER = BLOCKS.registerBlock(
"crystal_cluster", properties -> new AmethystClusterBlock(7.0F, 3.0F, properties),
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_CLUSTER));
public static final DeferredItem<BlockItem> CRYSTAL_CLUSTER_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_CLUSTER);
public static final DeferredItem<Item> CRYSTAL_SHARD = ITEMS.registerSimpleItem("crystal_shard");
public static final DeferredItem<PageItem> PAGE = ITEMS.registerItem("page", PageItem::new);
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
"ink_vial", InkVialItem::new, properties -> properties.stacksTo(16));
public static final DeferredItem<DescriptiveBookItem> DESCRIPTIVE_BOOK = ITEMS.registerItem(
"descriptive_book", DescriptiveBookItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<LinkingBookItem> LINKING_BOOK = ITEMS.registerItem(
"linking_book", LinkingBookItem::new, properties -> properties.stacksTo(1));
public static final Supplier<RecipeSerializer<LinkingBookRecipe>> LINKING_BOOK_RECIPE =
RECIPE_SERIALIZERS.register("linking_book", () -> LinkingBookRecipe.SERIALIZER);
public Rivencraft(IEventBus modEventBus) {
BLOCKS.register(modEventBus);
ITEMS.register(modEventBus);
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);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
modEventBus.addListener(AgeCatalogSync::registerPayloads);
modEventBus.addListener(PortBookNetworking::registerPayloads);
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
}
}
@@ -0,0 +1,22 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Acknowledges the exact catalog version applied on the client main thread. */
public record AgeCatalogAckPayload(int version) implements CustomPacketPayload {
public static final Type<AgeCatalogAckPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog_ack"));
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogAckPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, AgeCatalogAckPayload::version, AgeCatalogAckPayload::new
);
@Override
public Type<AgeCatalogAckPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,29 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Full server level catalog, sent on login and after a runtime Age is added. */
public record AgeCatalogPayload(int version, List<Identifier> dimensions) implements CustomPacketPayload {
public static final Type<AgeCatalogPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, AgeCatalogPayload::version,
Identifier.STREAM_CODEC.apply(ByteBufCodecs.list()), AgeCatalogPayload::dimensions,
AgeCatalogPayload::new
);
public AgeCatalogPayload {
dimensions = List.copyOf(dimensions);
}
@Override
public Type<AgeCatalogPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,102 @@
package net.dnorberg.rivencraft.age;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.server.ServerStoppedEvent;
import net.neoforged.neoforge.network.PacketDistributor;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
/** Refuses runtime-Age travel until the connected client confirms its catalog. */
public final class AgeCatalogSync {
private static final Map<MinecraftServer, State> STATES = new IdentityHashMap<>();
private AgeCatalogSync() {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC);
registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) acknowledge(player, payload.version());
});
}
public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
try {
send(player);
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not send initial Age catalog to {}; travel remains blocked",
player.getName().getString(), exception);
}
}
}
public static void onLogout(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
State state = STATES.get(player.level().getServer());
if (state != null) state.acknowledged.remove(player.getUUID());
}
}
public static void onServerStopped(ServerStoppedEvent event) {
STATES.remove(event.getServer());
}
public static void onCatalogChanged(MinecraftServer server) {
State state = state(server);
state.version++;
state.acknowledged.clear();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
try {
send(player);
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not send Age catalog to {}; travel remains blocked",
player.getName().getString(), exception);
}
}
}
public static boolean ready(ServerPlayer player, Identifier target) {
MinecraftServer server = player.level().getServer();
State state = state(server);
return state.version == state.acknowledged.getOrDefault(player.getUUID(), -1)
&& server.levelKeys().stream().anyMatch(key -> key.identifier().equals(target));
}
private static void send(ServerPlayer player) {
MinecraftServer server = player.level().getServer();
State state = state(server);
state.acknowledged.remove(player.getUUID());
var dimensions = server.levelKeys().stream().map(key -> key.identifier()).sorted().toList();
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions));
}
private static void acknowledge(ServerPlayer player, int version) {
State state = state(player.level().getServer());
if (version != state.version) {
Rivencraft.LOGGER.warn("Ignoring stale Age catalog acknowledgement {} from {} (current {})",
version, player.getName().getString(), state.version);
return;
}
state.acknowledged.put(player.getUUID(), version);
Rivencraft.LOGGER.info("Age catalog {} acknowledged by {}", version, player.getName().getString());
}
private static State state(MinecraftServer server) {
return STATES.computeIfAbsent(server, ignored -> new State());
}
private static final class State {
private int version = 1;
private final Map<UUID, Integer> acknowledged = new HashMap<>();
}
}
@@ -0,0 +1,117 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Server-owned seed index for the first-party 26.3 runtime-level prototype. */
final class AgeLevelIndex extends SavedData {
private static final Codec<AgeLevelIndex> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.unboundedMap(Identifier.CODEC, Codec.LONG).fieldOf("ages").forGetter(index -> index.ages),
Identifier.CODEC.listOf().optionalFieldOf("retired", List.of())
.forGetter(index -> List.copyOf(index.retired)),
Codec.unboundedMap(Identifier.CODEC, Identifier.CODEC).optionalFieldOf("biome_overrides", Map.of())
.forGetter(index -> index.biomeOverrides),
Codec.unboundedMap(Identifier.CODEC, PortBiomePlan.CODEC).optionalFieldOf("biome_plans", Map.of())
.forGetter(index -> index.biomePlans),
Codec.unboundedMap(Identifier.CODEC,
Codec.STRING.xmap(PortAgeTerrain::valueOf, PortAgeTerrain::name))
.optionalFieldOf("terrain", Map.of()).forGetter(index -> index.terrain)
).apply(instance, AgeLevelIndex::new));
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "runtime_ages"),
AgeLevelIndex::new,
CODEC
);
private final Map<Identifier, Long> ages;
private final Set<Identifier> retired;
private final Map<Identifier, Identifier> biomeOverrides;
private final Map<Identifier, PortBiomePlan> biomePlans;
private final Map<Identifier, PortAgeTerrain> terrain;
private 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,
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() {
return Map.copyOf(ages);
}
boolean contains(Identifier id) {
return ages.containsKey(id) || retired.contains(id);
}
Long seed(Identifier id) {
return ages.get(id);
}
PortBiomePlan biomePlan(Identifier id) {
PortBiomePlan plan = biomePlans.get(id);
if (plan != null) return plan;
Identifier oldBiome = biomeOverrides.get(id);
return oldBiome == null ? null : new PortBiomePlan(PortBiomePlan.Distribution.SINGLE, List.of(oldBiome));
}
PortAgeTerrain terrain(Identifier id) {
return terrain.getOrDefault(id, PortAgeTerrain.STANDARD);
}
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, 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();
}
void reseed(Identifier id, long seed) {
if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id);
ages.put(id, seed);
setDirty();
}
}
@@ -0,0 +1,288 @@
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;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.levelgen.Heightmap;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
import net.neoforged.fml.ModList;
import java.util.Map;
import java.util.TreeMap;
import java.util.Locale;
/** Operator-only backend and synchronized-travel probes, not normal book behavior. */
public final class PortAgeCommands {
private PortAgeCommands() {
}
public static void register(RegisterCommandsEvent event) {
event.getDispatcher().register(Commands.literal("rivencraft_port")
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
.then(Commands.literal("compat")
.executes(context -> {
StringBuilder mods = new StringBuilder("Optional mods: ");
for (String id : new String[]{"jei", "terrablender", "biomesoplenty",
"sereneseasons", "glitchcore", "ruins", "create",
"ponder", "immersive_portals_core"}) {
if (mods.length() > "Optional mods: ".length()) mods.append(", ");
mods.append(id).append('=').append(ModList.get().isLoaded(id));
}
context.getSource().sendSuccess(() -> Component.literal(mods.toString()), false);
Map<String, Integer> biomeCounts = new TreeMap<>();
context.getSource().getServer().registryAccess()
.lookupOrThrow(Registries.BIOME).listElementIds()
.forEach(key -> biomeCounts.merge(key.identifier().getNamespace(), 1, Integer::sum));
context.getSource().sendSuccess(() -> Component.literal(
"Registered biomes by namespace: " + biomeCounts), false);
return 1;
}))
.then(Commands.literal("link")
.then(Commands.literal("book")
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get());
LinkBookTarget target = LinkBookTarget.at(player);
book.set(Rivencraft.LINK_BOOK_TARGET.get(), target);
book.set(DataComponents.CUSTOM_NAME, Component.literal(
"Link to " + target.dimension()));
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
context.getSource().sendSuccess(() -> Component.literal(
"Granted Linking Book bound to " + target.dimension() + " at "
+ player.blockPosition()), false);
return 1;
})))
.then(Commands.literal("age")
.then(Commands.literal("create")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> 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, 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 flat or void"));
return 0;
}
if (terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID) {
context.getSource().sendFailure(Component.literal(
"Multi-biome probe supports flat or void only"));
return 0;
}
return createAge(context, terrain, new PortBiomePlan(
PortBiomePlan.Distribution.TILED, java.util.List.of(
Identifier.withDefaultNamespace("plains"),
Identifier.withDefaultNamespace("forest"))));
})))))
.then(Commands.literal("list")
.executes(context -> {
var ages = RuntimeAgeBackend.savedAges(context.getSource().getServer());
context.getSource().sendSuccess(() -> Component.literal(
"Saved prototype Ages: " + ages), false);
return ages.size();
}))
.then(Commands.literal("retire")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
String name = StringArgumentType.getString(context, "name");
Identifier id = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "age/" + name);
try {
RuntimeAgeBackend.retire(context.getSource().getServer(), id);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Retired " + id + "; exact Age data archived"), true);
return 1;
})))
.then(Commands.literal("unload")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
Identifier id = ageId(context);
try {
RuntimeAgeBackend.unload(context.getSource().getServer(), id);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Unloaded " + id), true);
return 1;
})))
.then(Commands.literal("reload")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
Identifier id = ageId(context);
try {
RuntimeAgeBackend.reload(context.getSource().getServer(), id);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Reloaded " + id), true);
return 1;
})))
.then(Commands.literal("reseed")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
Identifier id = ageId(context);
long seed = LongArgumentType.getLong(context, "seed");
try {
RuntimeAgeBackend.reseed(context.getSource().getServer(), id, seed);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Reseeded " + id + " to " + seed + " for future chunks"), true);
return 1;
}))))
.then(Commands.literal("enter")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
Identifier id = ageId(context);
String failure = PortAgeTravel.enter(player, id);
if (failure != null) {
context.getSource().sendFailure(Component.literal(failure));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Entered " + id + " after Age catalog acknowledgement"), false);
return 1;
})))
.then(Commands.literal("book")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
Identifier id = ageId(context);
if (!RuntimeAgeBackend.savedAges(context.getSource().getServer()).containsKey(id)) {
context.getSource().sendFailure(Component.literal("Age not active: " + id));
return 0;
}
ItemStack book = new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get());
book.set(Rivencraft.AGE_BOOK_ID.get(), id);
book.set(DataComponents.CUSTOM_NAME, Component.literal(
StringArgumentType.getString(context, "name")));
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
context.getSource().sendSuccess(() -> Component.literal(
"Granted bound Descriptive Book for " + id), false);
return 1;
})))
.then(Commands.literal("sample")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> sampleAge(context, 0))
.then(Commands.argument("chunkOffset", IntegerArgumentType.integer(0))
.executes(context -> sampleAge(context,
IntegerArgumentType.getInteger(context, "chunkOffset"))))))
.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,
int chunkOffset) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, id));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
int fingerprint = 1;
for (int x = 0; x < 2; x++) {
for (int z = 0; z < 2; z++) {
int chunkX = chunkOffset + x * 4;
int chunkZ = chunkOffset + z * 4;
level.getChunk(chunkX, chunkZ);
fingerprint = 31 * fingerprint + level.getHeight(
Heightmap.Types.WORLD_SURFACE, chunkX * 16, chunkZ * 16);
}
}
int result = fingerprint;
context.getSource().sendSuccess(() -> Component.literal(
id + " seed=" + level.getSeed() + " chunkOffset=" + chunkOffset + " terrain=" + result), false);
return 1;
}
private static 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,10 @@
package net.dnorberg.rivencraft.age;
/** Terrain variants currently backed by 26.3 vanilla noise settings. */
public enum PortAgeTerrain {
STANDARD,
AMPLIFIED,
ISLAND,
FLAT,
VOID
}
@@ -0,0 +1,82 @@
package net.dnorberg.rivencraft.age;
import java.util.Set;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
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 {
private PortAgeTravel() {
}
public static String enter(ServerPlayer player, Identifier id) {
ServerLevel target = player.level().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (target == null) return "Age not loaded: " + id;
if (!AgeCatalogSync.ready(player, id)) {
return "Age catalog not acknowledged by client; remaining in source dimension";
}
BlockPos landing = findLanding(target);
if (landing == null) return "No safe test landing available; remaining in source dimension";
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
return moved ? null : "Teleportation failed; remaining in source dimension";
}
private static BlockPos findLanding(ServerLevel target) {
// 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);
if (hasFooting(target, foot)) {
return foot;
}
}
}
}
// The operator/book probe needs a deterministic landing even when
// origin is ocean or foliage. Final book landing rules are ported later.
int y = Math.max(64, target.getHeight(Heightmap.Types.MOTION_BLOCKING, 0, 0) + 2);
if (y + 2 >= target.getMaxY()) return null;
BlockPos foot = new BlockPos(0, y, 0);
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
target.setBlock(new BlockPos(x, y - 1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
}
}
target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3);
target.setBlock(foot.above(), Blocks.AIR.defaultBlockState(), 3);
return foot;
}
private static boolean hasFooting(ServerLevel target, BlockPos foot) {
BlockPos floor = foot.below();
return target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP)
&& 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;
}
}
@@ -0,0 +1,372 @@
package net.dnorberg.rivencraft.age;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.registries.Registries;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.TicketStorage;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.FixedBiomeSource;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.Biomes;
import net.minecraft.world.level.biome.CheckerboardColumnBiomeSource;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.FlatLevelSource;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
import net.minecraft.world.level.levelgen.flat.FlatLayerInfo;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.DerivedLevelData;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.world.chunk.ForcedChunkManager;
import net.neoforged.neoforge.event.level.LevelEvent;
/**
* Server-side runtime-level foundation for 26.3. Authored generators and
* dynamic client registry synchronization remain separate migration steps.
*/
public final class RuntimeAgeBackend {
private RuntimeAgeBackend() {
}
public static void onLevelLoad(LevelEvent.Load event) {
if (!(event.getLevel() instanceof ServerLevel overworld) || overworld.dimension() != Level.OVERWORLD) {
return;
}
MinecraftServer server = overworld.getServer();
AgeLevelIndex index = index(server);
for (Map.Entry<Identifier, Long> age : index.entries().entrySet()) {
try {
load(server, age.getKey(), age.getValue(), 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);
}
}
}
/** 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, 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.
server.overworld().getDataStorage().saveAndJoin();
AgeCatalogSync.onCatalogChanged(server);
return level;
}
/** Unloads an Age only when no player remains; save(true) flushes other entities. */
public static void unload(MinecraftServer server, Identifier id) {
checkServerThread(server);
requireSeed(server, id);
unloadLoaded(server, id);
AgeCatalogSync.onCatalogChanged(server);
}
public static ServerLevel reload(MinecraftServer server, Identifier id) {
checkServerThread(server);
long seed = requireSeed(server, id);
ServerLevel level = load(server, id, seed, index(server).biomePlan(id), index(server).terrain(id));
AgeCatalogSync.onCatalogChanged(server);
return level;
}
/** Existing saved chunks stay in place; the replacement level uses the new seed. */
public static void reseed(MinecraftServer server, Identifier id, long seed) {
checkServerThread(server);
AgeLevelIndex index = index(server);
long previousSeed = requireSeed(server, id);
PortBiomePlan biomePlan = index.biomePlan(id);
PortAgeTerrain terrain = index.terrain(id);
if (previousSeed == seed) return;
boolean loaded = server.getLevel(dimensionKey(id)) != null;
if (loaded) unloadLoaded(server, id);
try {
if (loaded) load(server, id, seed, biomePlan, terrain);
index.reseed(id, seed);
server.overworld().getDataStorage().saveAndJoin();
if (loaded) AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException exception) {
if (loaded) {
try {
load(server, id, previousSeed, biomePlan, terrain);
AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
}
throw exception;
}
}
/** Retires one unloaded Age and moves only its own storage to a recoverable archive. */
public static void retire(MinecraftServer server, Identifier id) {
checkServerThread(server);
String name = ageName(id);
AgeLevelIndex index = index(server);
long seed = requireSeed(server, id);
PortBiomePlan biomePlan = index.biomePlan(id);
PortAgeTerrain terrain = index.terrain(id);
boolean loaded = server.getLevel(dimensionKey(id)) != null;
ArchivePaths paths = archivePaths(server, name);
if (loaded) unloadLoaded(server, id);
// Persist the tombstone before moving files: a crash mid-archive must
// not let an old book silently regenerate this identity.
index.retire(id);
try {
server.overworld().getDataStorage().saveAndJoin();
if (Files.exists(paths.live(), LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(paths.archive().getParent());
try {
Files.move(paths.live(), paths.archive(), StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(paths.live(), paths.archive());
}
}
} catch (IOException | RuntimeException exception) {
try {
index.restoreAfterFailedRetirement(id, seed, biomePlan, terrain);
server.overworld().getDataStorage().saveAndJoin();
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
if (loaded) {
try {
load(server, id, seed, biomePlan, terrain);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
}
throw new IllegalStateException("Could not archive Age " + id, exception);
}
AgeCatalogSync.onCatalogChanged(server);
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive());
}
public static Map<Identifier, Long> savedAges(MinecraftServer server) {
checkServerThread(server);
return index(server).entries();
}
/** Includes retired IDs, which must never be silently reused by a new book. */
public static boolean isNameTaken(MinecraftServer server, Identifier id) {
checkServerThread(server);
ageName(id);
return index(server).contains(id);
}
private static AgeLevelIndex index(MinecraftServer server) {
return server.overworld().getDataStorage().computeIfAbsent(AgeLevelIndex.TYPE);
}
private static void checkServerThread(MinecraftServer server) {
if (!server.isSameThread()) throw new IllegalStateException("Runtime Age operation outside server thread");
}
private static long requireSeed(MinecraftServer server, Identifier id) {
ageName(id);
Long seed = index(server).seed(id);
if (seed == null) throw new IllegalArgumentException("Age not active: " + id);
return seed;
}
private static String ageName(Identifier id) {
if (!id.getNamespace().equals(Rivencraft.MOD_ID) || !id.getPath().startsWith("age/")) {
throw new IllegalArgumentException("Expected a rivencraft:age/* dimension ID: " + id);
}
String name = id.getPath().substring("age/".length());
if (name.isBlank() || name.contains("/") || name.equals(".") || name.equals("..")) {
throw new IllegalArgumentException("Expected a single safe Age path segment: " + id);
}
return name;
}
private static ResourceKey<Level> dimensionKey(Identifier id) {
return ResourceKey.create(Registries.DIMENSION, id);
}
private static void unloadLoaded(MinecraftServer server, Identifier id) {
ResourceKey<Level> key = dimensionKey(id);
ServerLevel level = server.getLevel(key);
if (level == null) throw new IllegalStateException("Age not loaded: " + id);
if (!level.players().isEmpty()) {
throw new IllegalStateException("Age still contains players; move them before unloading: " + id);
}
// Match the server shutdown sequence so forced tickets are persisted
// as inactive, rather than holding stale references to this level.
level.getChunkSource().deactivateTicketsOnClosing();
level.getChunkSource().tick(() -> true, false);
level.save(null, true, false);
try {
level.close();
} catch (IOException exception) {
throw new IllegalStateException("Could not close Age: " + id, exception);
}
server.levels.remove(key);
NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level));
Rivencraft.LOGGER.info("Unloaded 26.3 prototype Age {}", id);
}
private static ArchivePaths archivePaths(MinecraftServer server, String name) {
Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
Path dimensions = world.resolve("dimensions");
Path namespace = dimensions.resolve(Rivencraft.MOD_ID);
Path ageRoot = namespace.resolve("age");
Path live = ageRoot.resolve(name).normalize();
Path archiveRoot = world.getParent().resolve(world.getFileName() + "-retired-ages").normalize();
Path archive = archiveRoot.resolve(name).normalize();
if (!live.getParent().equals(ageRoot) || !archive.getParent().equals(archiveRoot)
|| Files.isSymbolicLink(world) || Files.isSymbolicLink(dimensions)
|| Files.isSymbolicLink(namespace) || Files.isSymbolicLink(ageRoot)
|| Files.isSymbolicLink(live) || Files.isSymbolicLink(archiveRoot)
|| Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException("Unsafe or existing Age archive path for " + name);
}
return new ArchivePaths(live, archive);
}
private record ArchivePaths(Path live, Path archive) {
}
private static 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);
// 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");
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 = generator.getBiomeSource();
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 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()),
key, stem, server.getWorldData().isDebugWorld(), biomeZoomSeed, List.of(), false);
// Publish only after the constructor succeeds. If the load event fails,
// roll back the map and close the newly opened chunk/entity stores.
try {
server.levels.put(key, level);
level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize());
server.getPlayerList().addWorldborderListener(level);
NeoForge.EVENT_BUS.post(new LevelEvent.Load(level));
TicketStorage tickets = level.getDataStorage().get(TicketStorage.TYPE);
if (tickets != null) {
tickets.activateAllDeactivatedTickets();
ForcedChunkManager.activateAllDeactivatedTickets(level, tickets);
}
Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed);
return level;
} catch (RuntimeException exception) {
server.levels.remove(key);
try {
level.close();
} catch (IOException closeException) {
exception.addSuppressed(closeException);
}
throw exception;
}
}
}
@@ -0,0 +1,45 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.block.AmethystBlock;
import net.minecraft.world.level.block.AmethystClusterBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.BuddingAmethystBlock;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.Fluids;
/** Vanilla amethyst growth, using Rivencraft's blue crystal stages. */
public final class BuddingCrystalBlock extends AmethystBlock {
public BuddingCrystalBlock(BlockBehaviour.Properties properties) {
super(properties);
}
@Override
protected void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
if (random.nextInt(5) != 0) return;
Direction direction = Direction.values()[random.nextInt(Direction.values().length)];
BlockPos target = pos.relative(direction);
BlockState existing = level.getBlockState(target);
Block next = nextStage(existing, direction);
if (next == null) return;
level.setBlockAndUpdate(target, next.defaultBlockState()
.setValue(AmethystClusterBlock.FACING, direction)
.setValue(AmethystClusterBlock.WATERLOGGED,
existing.getFluidState().getType() == Fluids.WATER));
}
public static Block nextStage(BlockState existing, Direction direction) {
if (BuddingAmethystBlock.canClusterGrowAtState(existing)) return Rivencraft.SMALL_CRYSTAL_BUD.get();
if (!existing.hasProperty(AmethystClusterBlock.FACING)
|| existing.getValue(AmethystClusterBlock.FACING) != direction) return null;
if (existing.is(Rivencraft.SMALL_CRYSTAL_BUD)) return Rivencraft.MEDIUM_CRYSTAL_BUD.get();
if (existing.is(Rivencraft.MEDIUM_CRYSTAL_BUD)) return Rivencraft.LARGE_CRYSTAL_BUD.get();
if (existing.is(Rivencraft.LARGE_CRYSTAL_BUD)) return Rivencraft.CRYSTAL_CLUSTER.get();
return null;
}
}
@@ -0,0 +1,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,31 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.AgeCatalogAckPayload;
import net.dnorberg.rivencraft.age.AgeCatalogPayload;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.network.event.RegisterClientPayloadHandlersEvent;
import java.util.stream.Collectors;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class AgeCatalogClient {
private AgeCatalogClient() {
}
@SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
if (!(context.listener() instanceof ClientPacketListener listener)) return;
listener.levels = payload.dimensions().stream()
.map(id -> ResourceKey.<Level>create(Registries.DIMENSION, id))
.collect(Collectors.toUnmodifiableSet());
context.reply(new AgeCatalogAckPayload(payload.version()));
});
}
}
@@ -0,0 +1,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,29 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.client.Minecraft;
import net.minecraft.world.InteractionResult;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
/** Opens screens only on the physical client, including integrated singleplayer. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class PortBookInteractionHandler {
private PortBookInteractionHandler() {
}
@SubscribeEvent
public static void onRightClickItem(PlayerInteractEvent.RightClickItem event) {
if (!event.getEntity().level().isClientSide()) return;
boolean linking = event.getItemStack().is(Rivencraft.LINKING_BOOK.get());
boolean descriptive = event.getItemStack().is(Rivencraft.DESCRIPTIVE_BOOK.get());
if (!linking && !descriptive) return;
event.setCanceled(true);
event.setCancellationResult(InteractionResult.SUCCESS);
Minecraft.getInstance().setScreenAndShow(linking
? new PortLinkingBookScreen(event.getHand(), event.getItemStack().copy())
: new PortDescriptiveBookScreen(event.getHand(), event.getItemStack().copy()));
}
}
@@ -0,0 +1,116 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.network.BindDescriptiveBookPayload;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
/** Temporary 26.3 descriptive-book interaction; authored pages and live previews remain unported. */
public final class PortDescriptiveBookScreen extends Screen {
private static final int WIDTH = 327;
private static final int HEIGHT = 199;
private static final int TEXT_COLOR = 0xFF3B281B;
private static final Identifier COVER = texture("bookui_cover.png");
private static final Identifier LEFT_PAGE = texture("notebook_pages.png");
private static final Identifier RIGHT_PANEL = texture("bookui_pager.png");
private final InteractionHand hand;
private final boolean bound;
private EditBox titleInput;
public PortDescriptiveBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName());
this.hand = hand;
bound = book.has(Rivencraft.AGE_BOOK_ID.get());
}
@Override
protected void init() {
if (bound) return;
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
Component.literal("Age name"));
titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.literal("Name this Age"));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.literal("Bind"), button -> bind())
.bounds(left + 40, top + 100, 53, 20).build());
addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose())
.bounds(left + 97, top + 100, 53, 20).build());
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
if (bound) {
graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D);
graphics.text(font, Component.literal("Age preview unavailable"), left + 181, top + 47,
0xFFE9DFCB, false);
graphics.text(font, Component.literal("Click to enter"), left + 205, top + 65,
0xFFE9DFCB, false);
} else {
graphics.text(font, Component.literal("Bind a prototype Age"), left + 183, top + 39,
TEXT_COLOR, false);
}
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
if (event.x() >= left + 173 && event.x() < left + 305
&& event.y() >= top + 20 && event.y() < top + 103) {
ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(hand == InteractionHand.OFF_HAND));
onClose();
return true;
}
}
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean isPauseScreen() {
return false;
}
private void bind() {
if (titleInput == null) return;
String name = titleInput.getValue().strip();
if (name.isEmpty()) return;
ClientPacketDistributor.sendToServer(new BindDescriptiveBookPayload(hand == InteractionHand.OFF_HAND, name));
onClose();
}
private static Identifier texture(String filename) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename);
}
private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y,
int width, int height, int u, int v) {
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256);
}
}
@@ -0,0 +1,114 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.network.BindLinkingBookPayload;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.network.UseLinkingBookPayload;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
/** Minimal 26.3 book interaction using the Legacy artwork; live preview is not ported yet. */
public final class PortLinkingBookScreen extends Screen {
private static final int WIDTH = 327;
private static final int HEIGHT = 199;
private static final int TEXT_COLOR = 0xFF3B281B;
private static final Identifier COVER = texture("bookui_cover.png");
private static final Identifier LEFT_PAGE = texture("notebook_pages.png");
private static final Identifier RIGHT_PANEL = texture("bookui_pager.png");
private final InteractionHand hand;
private final boolean bound;
private EditBox titleInput;
public PortLinkingBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName());
this.hand = hand;
bound = book.has(Rivencraft.LINK_BOOK_TARGET.get());
}
@Override
protected void init() {
if (bound) return;
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
Component.literal("Linking Book name"));
titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.literal("Name this link"));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.literal("Bind"), button -> bind())
.bounds(left + 40, top + 100, 53, 20).build());
addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose())
.bounds(left + 97, top + 100, 53, 20).build());
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
if (bound) blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
if (bound) {
graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D);
graphics.text(font, Component.literal("Destination preview"), left + 185, top + 47, 0xFFE9DFCB, false);
graphics.text(font, Component.literal("Click to link"), left + 205, top + 65, 0xFFE9DFCB, false);
} else {
graphics.text(font, Component.literal("Name this Linking Book"), left + 183, top + 39,
TEXT_COLOR, false);
}
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
if (event.x() >= left + 173 && event.x() < left + 305
&& event.y() >= top + 20 && event.y() < top + 103) {
ClientPacketDistributor.sendToServer(new UseLinkingBookPayload(hand == InteractionHand.OFF_HAND));
onClose();
return true;
}
}
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean isPauseScreen() {
return false;
}
private void bind() {
if (titleInput == null) return;
String name = titleInput.getValue().strip();
if (name.isEmpty()) return;
ClientPacketDistributor.sendToServer(new BindLinkingBookPayload(hand == InteractionHand.OFF_HAND, name));
onClose();
}
private static Identifier texture(String filename) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename);
}
private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y,
int width, int height, int u, int v) {
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256);
}
}
@@ -0,0 +1,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,40 @@
package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
import net.minecraft.world.level.Level;
/** Descriptive Book shell for the 26.3 prototype Age backend. */
public final class DescriptiveBookItem extends Item {
public DescriptiveBookItem(Properties properties) {
super(properties);
}
@Override
public boolean isFoil(ItemStack stack) {
return stack.has(Rivencraft.AGE_BOOK_ID.get()) || super.isFoil(stack);
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
// The client opens the GUI. Binding and travel are validated on the
// server through dedicated packets; item use cannot bypass them.
return InteractionResult.SUCCESS;
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
tooltip.accept(Component.literal(id == null ? "Unbound (26.3 port preview)" : "Age: " + id));
}
}
@@ -0,0 +1,76 @@
package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.chat.Component;
import net.minecraft.util.Prediction;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
import net.minecraft.world.level.Level;
/** A stackable 16-use vial; using it with paper in the other hand prepares a Link Panel. */
public final class InkVialItem extends Item {
public static final int CAPACITY = 16;
// Match the 1.21.1 default; port the configurable cost with the full configuration system.
public static final int LINK_PANEL_COST = 2;
public InkVialItem(Properties properties) {
super(properties);
}
public static int remaining(ItemStack vial) {
return vial.getOrDefault(Rivencraft.INK_REMAINING.get(), CAPACITY);
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
InteractionHand paperHand = hand == InteractionHand.MAIN_HAND
? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
ItemStack vial = player.getItemInHand(hand);
ItemStack paper = player.getItemInHand(paperHand);
int ink = remaining(vial);
if (!vial.is(Rivencraft.INK_VIAL.get()) || !paper.is(Items.PAPER)
|| ink < LINK_PANEL_COST || ink > CAPACITY) {
return InteractionResult.PASS;
}
if (level.isClientSide()) return InteractionResult.SUCCESS;
ItemStack panel = PageItem.createLinkPanelPage();
int newInk = ink - LINK_PANEL_COST;
ItemStack spentVial = newInk == 0
? new ItemStack(Items.GLASS_BOTTLE)
: new ItemStack(Rivencraft.INK_VIAL.get());
if (newInk > 0) spentVial.set(Rivencraft.INK_REMAINING.get(), newInk);
if (paper.getCount() == 1) {
player.setItemInHand(paperHand, panel);
} else {
paper.shrink(1);
giveOrDrop(player, panel);
}
if (vial.getCount() == 1) {
player.setItemInHand(hand, spentVial);
} else {
vial.shrink(1);
giveOrDrop(player, spentVial);
}
return InteractionResult.SUCCESS;
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("item.rivencraft.ink_vial.remaining", remaining(stack), CAPACITY));
tooltip.accept(Component.translatable("item.rivencraft.ink_vial.usage"));
}
private static void giveOrDrop(Player player, ItemStack stack) {
if (!player.getInventory().add(stack)) player.drop(stack, false, Prediction.SERVER_ONLY);
}
}
@@ -0,0 +1,40 @@
package net.dnorberg.rivencraft.item;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
/** Persistent destination captured when a 26.3 prototype Linking Book is bound. */
public record LinkBookTarget(Identifier dimension, double x, double y, double z, float yaw, float pitch) {
public static final Codec<LinkBookTarget> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("dimension").forGetter(LinkBookTarget::dimension),
Codec.DOUBLE.fieldOf("x").forGetter(LinkBookTarget::x),
Codec.DOUBLE.fieldOf("y").forGetter(LinkBookTarget::y),
Codec.DOUBLE.fieldOf("z").forGetter(LinkBookTarget::z),
Codec.FLOAT.fieldOf("yaw").forGetter(LinkBookTarget::yaw),
Codec.FLOAT.fieldOf("pitch").forGetter(LinkBookTarget::pitch)
).apply(instance, LinkBookTarget::new));
public static final StreamCodec<RegistryFriendlyByteBuf, LinkBookTarget> STREAM_CODEC = StreamCodec.composite(
Identifier.STREAM_CODEC, LinkBookTarget::dimension,
ByteBufCodecs.DOUBLE, LinkBookTarget::x,
ByteBufCodecs.DOUBLE, LinkBookTarget::y,
ByteBufCodecs.DOUBLE, LinkBookTarget::z,
ByteBufCodecs.FLOAT, LinkBookTarget::yaw,
ByteBufCodecs.FLOAT, LinkBookTarget::pitch,
LinkBookTarget::new);
public static LinkBookTarget at(ServerPlayer player) {
return new LinkBookTarget(player.level().dimension().identifier(),
player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot());
}
public boolean valid() {
return Double.isFinite(x) && Double.isFinite(y) && Double.isFinite(z)
&& Float.isFinite(yaw) && Float.isFinite(pitch)
&& Math.abs(x) <= 30_000_000 && Math.abs(z) <= 30_000_000;
}
}
@@ -0,0 +1,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;
}
}
@@ -0,0 +1,119 @@
package net.dnorberg.rivencraft.item;
import java.util.Set;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.AgeCatalogSync;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
import net.minecraft.world.level.Level;
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 {
public LinkingBookItem(Properties properties) {
super(properties);
}
@Override
public boolean isFoil(ItemStack stack) {
return stack.has(Rivencraft.LINK_BOOK_TARGET.get()) || super.isFoil(stack);
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
// Client-side interaction opens the book screen. A direct server-side
// item-use packet must never bypass the screen's validated payloads.
return InteractionResult.SUCCESS;
}
public static String travel(ServerPlayer player, 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())
&& !properties.has(LinkProperties.INTRA_LINKING)) {
return "Same-dimension linking requires an Intra-Linking page";
}
ServerLevel target = player.level().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, destination.dimension()));
if (target == null) return "Destination dimension is unavailable: " + destination.dimension();
if (!AgeCatalogSync.ready(player, destination.dimension())) {
return "Destination catalog not acknowledged by client";
}
BlockPos feet = BlockPos.containing(destination.x(), destination.y(), destination.z());
if (feet.getY() < target.getMinY() || feet.getY() + 1 >= target.getMaxY()
|| !target.getWorldBorder().isWithinBounds(feet)) {
return "Destination is outside the world bounds";
}
// Test the player's actual arrival volume: checking whole block shapes
// would incorrectly reject links bound while standing on a slab.
if (!target.noCollision(player, player.getBoundingBox().move(
destination.x() - player.getX(), destination.y() - player.getY(),
destination.z() - player.getZ()))) {
return "Linked destination is obstructed";
}
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) {
LinkBookTarget target = stack.get(Rivencraft.LINK_BOOK_TARGET.get());
tooltip.accept(Component.literal(target == null ? "Unbound (26.3 port preview)"
: "Destination: " + target.dimension() + " (" + (int) target.x() + ", "
+ (int) target.y() + ", " + (int) target.z() + ")"));
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"));
}
}
@@ -0,0 +1,94 @@
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;
import net.minecraft.world.item.component.TooltipDisplay;
/** One Page item with a persistent Link Panel variant; symbol writing is not ported yet. */
public final class PageItem extends Item {
public PageItem(Properties properties) {
super(properties);
}
public static ItemStack createLinkPanelPage() {
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;
}
public static boolean isLinkPanel(ItemStack stack) {
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");
}
@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; }
}
@@ -0,0 +1,22 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record BindDescriptiveBookPayload(boolean offhand, String title) implements CustomPacketPayload {
public static final Type<BindDescriptiveBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_descriptive_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, BindDescriptiveBookPayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.BOOL, BindDescriptiveBookPayload::offhand,
ByteBufCodecs.stringUtf8(32), BindDescriptiveBookPayload::title,
BindDescriptiveBookPayload::new);
@Override
public Type<BindDescriptiveBookPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,22 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record BindLinkingBookPayload(boolean offhand, String title) implements CustomPacketPayload {
public static final Type<BindLinkingBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_linking_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, BindLinkingBookPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, BindLinkingBookPayload::offhand,
ByteBufCodecs.stringUtf8(32), BindLinkingBookPayload::title,
BindLinkingBookPayload::new);
@Override
public Type<BindLinkingBookPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,155 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortAgeTravel;
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.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;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.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 {
public static final int MAX_BOOK_TITLE_LENGTH = 32;
private PortBookNetworking() {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) bind(player, payload);
});
registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) use(player, payload);
});
registrar.playToServer(BindDescriptiveBookPayload.TYPE, BindDescriptiveBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) bindAge(player, payload);
});
registrar.playToServer(UseDescriptiveBookPayload.TYPE, UseDescriptiveBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) enterAge(player, payload);
});
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) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.LINKING_BOOK.get()) || stack.has(Rivencraft.LINK_BOOK_TARGET.get())) return;
String title = payload.title().strip();
if (title.isEmpty() || title.length() > MAX_BOOK_TITLE_LENGTH) return;
stack.set(Rivencraft.LINK_BOOK_TARGET.get(), LinkBookTarget.at(player));
stack.set(DataComponents.CUSTOM_NAME, Component.literal(title));
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN,
SoundSource.PLAYERS, 1.0F, 1.0F);
player.sendOverlayMessage(Component.literal("Bound Linking Book: " + title));
player.containerMenu.broadcastChanges();
}
private static void use(ServerPlayer player, UseLinkingBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.LINKING_BOOK.get())) return;
String failure = LinkingBookItem.travel(player, stack);
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
}
private static void bindAge(ServerPlayer player, BindDescriptiveBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()) || stack.has(Rivencraft.AGE_BOOK_ID.get())) return;
String title = payload.title().strip();
if (title.isEmpty() || title.length() > MAX_BOOK_TITLE_LENGTH) return;
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";
var server = player.level().getServer();
String name = base;
int suffix = 2;
Identifier id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
while (RuntimeAgeBackend.isNameTaken(server, id)) {
name = base + "_" + suffix++;
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
}
try {
RuntimeAgeBackend.create(server, id, player.getRandom().nextLong(), biomePlan,
PortAuthoredWriting.terrain(pages));
} catch (IllegalArgumentException | IllegalStateException exception) {
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
return null;
}
return id;
}
private static void enterAge(ServerPlayer player, UseDescriptiveBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.DESCRIPTIVE_BOOK.get())) return;
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
if (id == null) return;
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) {
return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
}
}
@@ -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; }
}
@@ -0,0 +1,21 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record UseDescriptiveBookPayload(boolean offhand) implements CustomPacketPayload {
public static final Type<UseDescriptiveBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_descriptive_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, UseDescriptiveBookPayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.BOOL, UseDescriptiveBookPayload::offhand,
UseDescriptiveBookPayload::new);
@Override
public Type<UseDescriptiveBookPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,20 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record UseLinkingBookPayload(boolean offhand) implements CustomPacketPayload {
public static final Type<UseLinkingBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_linking_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, UseLinkingBookPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, UseLinkingBookPayload::offhand, UseLinkingBookPayload::new);
@Override
public Type<UseLinkingBookPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,71 @@
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;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredRegister;
/** Only items already ported to 26.3 appear here. */
public final class ModCreativeTabs {
private static final DeferredRegister<CreativeModeTab> TABS =
DeferredRegister.create(Registries.CREATIVE_MODE_TAB, Rivencraft.MOD_ID);
static {
TABS.register("rivencraft", () -> CreativeModeTab.builder()
.title(Component.translatable("itemGroup.rivencraft"))
.icon(() -> Rivencraft.DESCRIPTIVE_BOOK.get().getDefaultInstance())
.displayItems((parameters, output) -> {
output.accept(Rivencraft.DESCRIPTIVE_BOOK.get());
output.accept(Rivencraft.LINKING_BOOK.get());
output.accept(Rivencraft.CRYSTAL_BLOCK_ITEM.get());
output.accept(Rivencraft.BUDDING_CRYSTAL_ITEM.get());
output.accept(Rivencraft.SMALL_CRYSTAL_BUD_ITEM.get());
output.accept(Rivencraft.MEDIUM_CRYSTAL_BUD_ITEM.get());
output.accept(Rivencraft.LARGE_CRYSTAL_BUD_ITEM.get());
output.accept(Rivencraft.CRYSTAL_CLUSTER_ITEM.get());
output.accept(Rivencraft.CRYSTAL_SHARD.get());
output.accept(Rivencraft.INK_VIAL.get());
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) -> {
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() {
}
public static void register(IEventBus modEventBus) {
TABS.register(modEventBus);
}
}
@@ -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);
}
}
@@ -0,0 +1,66 @@
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;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.crafting.CraftingInput;
import net.minecraft.world.item.crafting.CustomRecipe;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.level.Level;
/** Legacy's shapeless leather + Link Panel recipe; never accepts a blank Page. */
public final class LinkingBookRecipe extends CustomRecipe {
private static final LinkingBookRecipe INSTANCE = new LinkingBookRecipe();
public static final MapCodec<LinkingBookRecipe> MAP_CODEC = MapCodec.unit(INSTANCE);
public static final StreamCodec<RegistryFriendlyByteBuf, LinkingBookRecipe> STREAM_CODEC = StreamCodec.unit(INSTANCE);
public static final RecipeSerializer<LinkingBookRecipe> SERIALIZER = new RecipeSerializer<>(MAP_CODEC, STREAM_CODEC);
private LinkingBookRecipe() {
}
private static ItemStack findPanel(CraftingInput input) {
if (input.ingredientCount() != 2) return ItemStack.EMPTY;
ItemStack panel = ItemStack.EMPTY;
boolean leather = false;
for (ItemStack stack : input.items()) {
if (stack.isEmpty()) continue;
if (PageItem.isLinkPanel(stack) && panel.isEmpty()) {
panel = stack;
} else if (stack.is(Items.LEATHER) && !leather) {
leather = true;
} else {
return ItemStack.EMPTY;
}
}
return leather ? panel : ItemStack.EMPTY;
}
@Override
public boolean matches(CraftingInput input, Level level) {
return !findPanel(input).isEmpty();
}
@Override
public ItemStack assemble(CraftingInput input) {
ItemStack panel = findPanel(input);
if (panel.isEmpty()) return ItemStack.EMPTY;
ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get());
if (panel.has(DataComponents.CUSTOM_NAME)) {
book.set(DataComponents.CUSTOM_NAME, panel.get(DataComponents.CUSTOM_NAME));
}
LinkProperties properties = panel.get(Rivencraft.LINK_PROPERTIES.get());
if (properties != null) book.set(Rivencraft.LINK_PROPERTIES.get(), properties);
return book;
}
@Override
public RecipeSerializer<LinkingBookRecipe> getSerializer() {
return SERIALIZER;
}
}
@@ -0,0 +1,115 @@
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 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);
}
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;
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;
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);
if (registry.get(ResourceKey.create(Registries.BIOME, biome)).isEmpty())
throw new IllegalArgumentException("Unknown authored 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 -> isCave(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();
}
private static boolean isCave(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,8 @@
# The 26.3 server has no public runtime-Level insertion API. Keep the
# access surface restricted to the three fields used by RuntimeAgeBackend.
public net.minecraft.server.MinecraftServer levels
public net.minecraft.server.MinecraftServer storageSource
public net.minecraft.server.MinecraftServer executor
# Minecraft 26.3 may expose an immutable level catalog. Replace the field
# atomically when the server announces new runtime Ages instead of mutating it.
public net.minecraft.client.multiplayer.ClientPacketListener levels
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/book_binder"
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/budding_crystal"}}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:block/crystal_block"
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/crystal_cluster"}}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/crystal_shard"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/descriptive_book"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/ink_mixer"
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/ink_vial"}}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/large_crystal_bud"}}
@@ -0,0 +1,15 @@
{
"model": {
"type": "minecraft:condition",
"property": "minecraft:has_component",
"component": "rivencraft:link_book_target",
"on_true": {
"type": "minecraft:model",
"model": "rivencraft:item/linking_book_bound"
},
"on_false": {
"type": "minecraft:model",
"model": "rivencraft:item/linking_book_unwritten"
}
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/medium_crystal_bud"}}
@@ -0,0 +1,19 @@
{
"model": {
"type": "minecraft:condition",
"property": "minecraft:has_component",
"component": "rivencraft:link_panel_page",
"on_true": {"type": "minecraft:model", "model": "rivencraft:item/page_link_panel"},
"on_false": {
"type": "minecraft: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"}
}
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/small_crystal_bud"}}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/linking_book_unwritten"
}
}
@@ -0,0 +1 @@
{"parent":"minecraft:item/generated","textures":{"layer0":"rivencraft:item/page"}}
@@ -0,0 +1 @@
{"type":"minecraft:block","random_sequence":"rivencraft:blocks/budding_crystal"}
@@ -0,0 +1,23 @@
{
"type": "minecraft:block",
"pools": [{
"entries": [{
"type": "minecraft:alternatives",
"children": [
{"type": "minecraft:item", "condition": "minecraft:tool/can_silk_touch", "name": "rivencraft:crystal_cluster"},
{"type": "minecraft:alternatives", "children": [
{"type": "minecraft:item", "condition": {"type": "minecraft:match_tool", "predicate": {"items": "#minecraft:cluster_max_harvestables"}}, "modifier": [
{"type": "minecraft:set_count", "count": 4},
{"type": "minecraft:apply_bonus", "enchantment": "minecraft:fortune", "formula": "minecraft:ore_drops"}
], "name": "rivencraft:crystal_shard"},
{"type": "minecraft:item", "modifier": [
{"type": "minecraft:set_count", "count": 2},
{"type": "minecraft:explosion_decay"}
], "name": "rivencraft:crystal_shard"}
]}
]
}],
"rolls": 1
}],
"random_sequence": "rivencraft:blocks/crystal_cluster"
}
@@ -0,0 +1 @@
{"type":"minecraft:block","pools":[{"condition":"minecraft:tool/can_silk_touch","entries":[{"type":"minecraft:item","name":"rivencraft:large_crystal_bud"}],"rolls":1}],"random_sequence":"rivencraft:blocks/large_crystal_bud"}
@@ -0,0 +1 @@
{"type":"minecraft:block","pools":[{"condition":"minecraft:tool/can_silk_touch","entries":[{"type":"minecraft:item","name":"rivencraft:medium_crystal_bud"}],"rolls":1}],"random_sequence":"rivencraft:blocks/medium_crystal_bud"}
@@ -0,0 +1 @@
{"type":"minecraft:block","pools":[{"condition":"minecraft:tool/can_silk_touch","entries":[{"type":"minecraft:item","name":"rivencraft:small_crystal_bud"}],"rolls":1}],"random_sequence":"rivencraft:blocks/small_crystal_bud"}
@@ -0,0 +1,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}
}
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more