Compare commits

...
12 Commits
Author SHA1 Message Date
dnorberg b9faa28dd5 Refine Cave terrain environment and spawning (#14)
Build / build (push) Canceled after 0s
2026-10-21 08:38:23 -04:00
dnorberg 09d2b0800c Complete Link Modifier controls and seed-driven Age generation (#90)
Build / build (push) Canceled after 0s
2026-10-21 08:11:26 -04:00
dnorberg ee905cab68 Add Legacy missing items and OP Link Modifier (#90)
Build / build (push) Canceled after 0s
2026-10-20 17:50:09 -04:00
dnorberg 5c08f391a2 Update optional Serene Seasons compatibility to 10.1.0.9
Build / build (push) Canceled after 0s
2026-10-20 17:33:07 -04:00
dnorberg b8d26a9036 Update optional JEI compatibility to 19.69.0.457
Build / build (push) Canceled after 0s
2026-10-20 17:12:21 -04:00
dnorberg d0af75239a Match Legacy handheld book drops with model entity (#85)
Build / build (push) Canceled after 0s
2026-10-19 16:41:07 -04:00
dnorberg 2e5d6e0427 Adopt NeoForge 21.1.264 after compatibility testing
Build / build (push) Canceled after 0s
2026-10-19 05:37:46 -04:00
dnorberg 54c10cca6c Default automatic Return Links to disabled and review compatibility
Build / build (push) Canceled after 0s
2026-10-19 05:21:00 -04:00
dnorberg c39ebd78ba Add Milestone 7 progression advancements
Build / build (push) Canceled after 0s
2026-10-19 04:57:15 -04:00
dnorberg 1ff62e9aaa Polish Milestone 7 models textures and link effects
Build / build (push) Canceled after 0s
2026-10-19 04:32:35 -04:00
dnorberg b154455563 Complete Milestone 7 crafting and portal guide (#79)
Build / build (push) Canceled after 0s
2026-10-19 04:01:03 -04:00
dnorberg 4a87c9fae6 Harden crystal portals across restart and protected travel
Build / build (push) Canceled after 0s
2026-10-18 23:17:16 -04:00
136 changed files with 4307 additions and 273 deletions

No files matched your search

+16 -2
View File
@@ -105,6 +105,20 @@ JEI is optional; vanilla crafting
recipes and all writing interactions work without it. For a development client recipes and all writing interactions work without it. For a development client
with JEI, run `./gradlew runClient -PwithJei=true`. with JEI, run `./gradlew runClient -PwithJei=true`.
Four Crystal Shards craft one Crystal Block; eight Crystal Blocks around an
empty crafting-grid center make one Book Receptacle. Picking up a shard unlocks
the block recipe, and picking up a block unlocks the Receptacle recipe. The
existing Ink Vial recipe uses two black dyes, a glass bottle, and a water
bucket; the empty bucket stays in the crafting grid. These JSON recipes appear
in JEI when it is installed and can be replaced by a data pack at
`data/rivencraft/recipe/<recipe_name>.json`. To test a replacement, use a fresh
world or `/reload`, then check the result in a crafting table and vanilla
Crafter. A vanilla Lectern converts to a Rivencraft Book Stand when a Rivencraft
book or page is placed on it; this in-world interaction has no JEI guide.
With Create installed, Crystal Blocks and Book Receptacles have an optional Ponder scene
showing crystal-frame construction, book activation, and a parrot crossing the portal. Use the Ponder shortcut
while hovering either item; Rivencraft does not require Create to run.
Craft a Book Binder from three iron ingots and five planks. Put leather or an Craft a Book Binder from three iron ingots and five planks. Put leather or an
empty Notebook in the left cover slot. Enter a title, then place a Link Panel empty Notebook in the left cover slot. Enter a title, then place a Link Panel
Page first in the horizontal page strip. Click a page in the strip with a Page first in the horizontal page strip. Click a page in the strip with a
@@ -184,7 +198,7 @@ Linking creates a bound Return Link to the exact source position and orientation
according to the configured Return Link policy. according to the configured Return Link policy.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`. server config to `DISABLED` (default), `ON_CREATION`, or `ALWAYS`.
Age creation, persistence, lifecycle commands, configuration, backups, and Age creation, persistence, lifecycle commands, configuration, backups, and
administrator recovery are documented in [docs/AGES.md](docs/AGES.md). administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
Data-pack runtime profiles are documented in Data-pack runtime profiles are documented in
@@ -194,7 +208,7 @@ Data-pack runtime profiles are documented in
- A Java 21 JDK - A Java 21 JDK
- Minecraft 1.21.1 - Minecraft 1.21.1
- NeoForge 21.1.251 or newer in the 21.1 line - NeoForge 21.1.264 or newer in the 21.1 line
- Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server) - Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server)
Create is optional. When installed, Rivencraft uses its Ponder renderer for Create is optional. When installed, Rivencraft uses its Ponder renderer for
+2
View File
@@ -132,6 +132,8 @@ neoForge {
server { server {
server() server()
// Keep dedicated compatibility runs away from the normal dev world.
gameDirectory = project.file(project.findProperty('serverRunDir') ?: 'run')
programArgument '--nogui' programArgument '--nogui'
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+2 -3
View File
@@ -69,10 +69,9 @@ definition.
Rivencraft writes gameplay settings to the world/server configuration managed Rivencraft writes gameplay settings to the world/server configuration managed
by NeoForge. `descriptive_books.return_link_mode` accepts: by NeoForge. `descriptive_books.return_link_mode` accepts:
- `ON_CREATION` (default): create one Return Link the first time a player - `DISABLED` (default): never create Return Links automatically.
enters each Age. - `ON_CREATION`: create one Return Link the first time a player enters each Age.
- `ALWAYS`: create a Return Link after every successful Descriptive Book trip. - `ALWAYS`: create a Return Link after every successful Descriptive Book trip.
- `DISABLED`: never create Return Links automatically.
Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode` Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode`
setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes
+3 -3
View File
@@ -1,7 +1,7 @@
# Age arrival cloud platform # Age arrival platform
Newly created Ages now place a 5×5 solid cloud platform instead of cobblestone at their saved spawn. The block has full collision, passes skylight, renders translucently, has no loot table, and cannot be obtained with pick-block. Existing Ages retain their already-placed platform; this change does not overwrite a player's saved world. Newly created Ages place a 5×5 solid platform instead of cobblestone at their saved spawn. The block has full collision, renders as an opaque full block, breaks as easily as glass (0.3 hardness), has no loot table, and cannot be obtained with pick-block. Existing Ages retain their already-placed platform blocks, which adopt the new appearance and hardness without altering saved terrain.
The block texture is `assets/rivencraft/textures/block/cloud_platform.png`, verified at exactly 16×16 RGBA. Legacy's supplied assets contain no cloud-block texture, so this one was generated with the built-in image tool, then reduced by nearest-neighbor sampling. The final prompt requested a seamless Minecraft block-face pixel-art tile with 2–3 chunky white, icy-blue, and powder-blue cloud swirls, genuine transparent holes between them, strong 16×16-scale contrast, and no background or text. The model reuses `assets/rivencraft/textures/block/crystal_block.png`, the 16×16 Legacy crystal tile. The former generated cloud texture is removed. The internal block ID remains `rivencraft:cloud_platform` to preserve saved worlds.
The separate [decay provider](INSTABILITY_DECAY.md) protects the saved spawn area by position, so its protection remains in force regardless of the platform's material. The separate [decay provider](INSTABILITY_DECAY.md) protects the saved spawn area by position, so its protection remains in force regardless of the platform's material.
+21
View File
@@ -40,6 +40,27 @@ profiles, not claims that Legacy had identically named terrain symbols.
Every family retains a safe cobblestone arrival platform. An omitted terrain Every family retains a safe cobblestone arrival platform. An omitted terrain
page still uses the deterministic seed-selected profile. page still uses the deterministic seed-selected profile.
### Cave terrain and Legacy
Legacy's closest terrain generator is its Nether terrain symbol: it owns an
Age-seeded octave-noise generator, accepts terrain/sea materials, and sets a
sea level of 32. Legacy's separate Caves symbol instead registers a terrain
alteration that carves additional caves; it is not a terrain generator.
Minecraft 1.21.1 no longer has Legacy's 1.12.2 height and generation pipeline,
so Rivencraft Cave terrain uses `minecraft:caves` noise and the modern Cave,
Nether, and tagged modded Nether biome decorations. It cannot reproduce the
exact Legacy noise or its older vertical layout. The Caves feature page
remains a separate additive carver for other suitable terrain.
Cave Ages search for safe spawn footing from Y=64 downward rather than using
the cave roof; if necessary, the usual cloud platform is placed at Y=63.
Weather is clear and Serene Seasons is disabled in Cave terrain. Authored
Weather, Season, and Celestial pages (including time modifiers) are retained
in the book but have no effect on the Cave Age; each adds 100 base instability.
The same policy applies to randomly selected Cave terrain and persists through
save/reload. Cave biome selections remain limited to Cave and Nether biomes;
surface-biome completion is not used for Cave terrain.
The separate **Floating Islands** feature page adds rare high-altitude island The separate **Floating Islands** feature page adds rare high-altitude island
clusters above Standard, Amplified, Sky Islands, Flat, or Void terrain; it does clusters above Standard, Amplified, Sky Islands, Flat, or Void terrain; it does
not replace the terrain page. The feature is seeded by the Age and saved with the written not replace the terrain page. The feature is seeded by the Age and saved with the written
+4 -2
View File
@@ -19,12 +19,12 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture | | `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture |
| `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel | | `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel |
| `assets/rivencraft/textures/gui/ink_mixer_legacy.png` | `assets/mystcraft/gui/inkmixer.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Ink Mixer GUI uses the original 176×181 panel | | `assets/rivencraft/textures/gui/ink_mixer_legacy.png` | `assets/mystcraft/gui/inkmixer.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Ink Mixer GUI uses the original 176×181 panel |
| `assets/rivencraft/textures/block/ink_mixer_top.png`, `ink_mixer_side.png`, `ink_mixer_bottom.png` | `assets/mystcraft/textures/blocks/inkmixer_top.png`, `inkmixer_side.png`, `inkmixer_bottom.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Rivencraft supplies its own block model | | `assets/rivencraft/textures/block/inkwell_base.png`, `inkwell_cup.png`, `ink.png`, `ink_glass.png` | `assets/mystcraft/textures/entity/inkwell/` counterparts | Legacy 0.13.7.06 local extraction | Byte-identical copies used by the ported Ink Mixer geometry; the paper overlay uses vanilla white concrete instead of Legacy's black surface |
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only | | `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample | | `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
| `assets/rivencraft/textures/gui/symbolcomponents.png` | `assets/mystcraft/textures/symbolcomponents.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Byte-identical shared 512×512 component sheet for client-side page composition | | `assets/rivencraft/textures/gui/symbolcomponents.png` | `assets/mystcraft/textures/symbolcomponents.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Byte-identical shared 512×512 component sheet for client-side page composition |
| Runtime-composed symbol pages (`SymbolPageTextures`) | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Replaces the six pre-generated symbol PNGs; layers four poem words on the shared page background and caches bounded client textures | | Runtime-composed symbol pages (`SymbolPageTextures`) | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Replaces the six pre-generated symbol PNGs; layers four poem words on the shared page background and caches bounded client textures |
| `assets/rivencraft/textures/block/crystal_block.png` | `assets/mystcraft/textures/blocks/crystal.png` | Legacy 0.13.7.06 local extraction | Exact 16×16 blue tile shared by Crystal Block and Budding Crystal models after removing a byte-identical duplicate | | `assets/rivencraft/textures/block/crystal_block.png` | `assets/mystcraft/textures/blocks/crystal.png` | Legacy 0.13.7.06 local extraction | Exact 16×16 blue tile shared by Crystal Block, Budding Crystal, and the non-dropping Age arrival platform; its former cloud sprite is removed |
| `assets/rivencraft/textures/block/{small,medium,large}_crystal_bud.png`, `crystal_cluster.png` | Minecraft 1.21.1 `textures/block/{small,medium,large}_amethyst_bud.png`, `amethyst_cluster.png` | Minecraft 1.21.1 | Original 16×16 alpha masks and silhouettes preserved; hue shifted toward Legacy blue, with brighter saturation; exported as 8-bit RGBA (not indexed PNG) | | `assets/rivencraft/textures/block/{small,medium,large}_crystal_bud.png`, `crystal_cluster.png` | Minecraft 1.21.1 `textures/block/{small,medium,large}_amethyst_bud.png`, `amethyst_cluster.png` | Minecraft 1.21.1 | Original 16×16 alpha masks and silhouettes preserved; hue shifted toward Legacy blue, with brighter saturation; exported as 8-bit RGBA (not indexed PNG) |
| `assets/rivencraft/textures/item/crystal_shard.png` | Original sprite generated with OpenAI ImageGen from the Legacy crystal palette | 2026-10-05 | Cropped and nearest-neighbor scaled to 16×16 | | `assets/rivencraft/textures/item/crystal_shard.png` | Original sprite generated with OpenAI ImageGen from the Legacy crystal palette | 2026-10-05 | Cropped and nearest-neighbor scaled to 16×16 |
| `assets/rivencraft/sounds/linking/link-fissure.ogg` | `assets/mystcraft/sounds/linking/link-fissure.ogg` | Legacy 0.13.7.06 local extraction | Namespace/path rename only; plays when a Star Fissure returns an entity home | | `assets/rivencraft/sounds/linking/link-fissure.ogg` | `assets/mystcraft/sounds/linking/link-fissure.ogg` | Legacy 0.13.7.06 local extraction | Namespace/path rename only; plays when a Star Fissure returns an entity home |
@@ -32,6 +32,8 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| `assets/rivencraft/textures/block/book_receptacle.png` | `assets/mystcraft/textures/blocks/book_receptacle.png` | Legacy 0.13.7.06 local extraction | Byte-identical 16×16 face tile; modern block model recreates the Legacy thin slab | | `assets/rivencraft/textures/block/book_receptacle.png` | `assets/mystcraft/textures/blocks/book_receptacle.png` | Legacy 0.13.7.06 local extraction | Byte-identical 16×16 face tile; modern block model recreates the Legacy thin slab |
| `assets/rivencraft/textures/block/crystal_portal.png` and `.mcmeta` | `assets/mystcraft/textures/blocks/portal.png` and `.mcmeta` | Legacy 0.13.7.06 local extraction | Byte-identical texture and line-ending-normalized animation metadata, used when Immersive Portals is absent | | `assets/rivencraft/textures/block/crystal_portal.png` and `.mcmeta` | `assets/mystcraft/textures/blocks/portal.png` and `.mcmeta` | Legacy 0.13.7.06 local extraction | Byte-identical texture and line-ending-normalized animation metadata, used when Immersive Portals is absent |
| `assets/rivencraft/sounds/linking/link-portal.ogg` | `assets/mystcraft/sounds/linking/link-portal.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical portal-link sound | | `assets/rivencraft/sounds/linking/link-portal.ogg` | `assets/mystcraft/sounds/linking/link-portal.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical portal-link sound |
| `assets/rivencraft/sounds/linking/link-disarm.ogg` | `assets/mystcraft/sounds/linking/link-disarm.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical disarm-link sound; takes precedence over the portal sound as in Legacy |
| `assets/rivencraft/sounds/linking/link-intra.ogg` | `assets/mystcraft/sounds/linking/link-intra.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical intra-link sound for Linking Books |
Mystcraft Legacy identifies XCompWiz as its project developer and includes the Mystcraft Legacy identifies XCompWiz as its project developer and includes the
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE` full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
+8 -4
View File
@@ -15,9 +15,13 @@ Rivencraft has books, Ages, symbol writing, book stands, a desk, binder, Ink Mix
## Crystal portals: targets for #77–#78 ## Crystal portals: targets for #77–#78
Issue #77 now has a craftable Book Receptacle (eight Crystal Blocks around an empty center), a bounded loaded-chunk crystal-network validator, persistent book holder/source pointers, animated Legacy portal cells, and portal collision routed through the existing Linking/Descriptive Book travel services. A book is only taken after a nonempty frame plan and destination dimension are validated. Removing the book or breaking its support removes the portal; a periodic server repair pass checks the loaded frame after restarts or edits. The Descriptive Book path preserves the configured automatic Return Link policy and link modifiers. Crystal-portal arrival does not generate a landing platform, matching Legacy's external travel semantics. A normal Linking Book still needs Intra Linking for same-dimension travel. Issue #77 now has a craftable Book Receptacle (eight Crystal Blocks around an empty center), a bounded loaded-chunk crystal-network validator, persistent book holder/source pointers, animated Legacy portal cells, and portal collision routed through the existing Linking/Descriptive Book travel services. A book is only taken after a nonempty frame plan and destination dimension are validated. Removing the book or breaking its support removes the portal; portal cells validate on neighboring block changes, like vanilla portals, rather than self-destructing on recurring ticks during chunk loading. The Descriptive Book path preserves the configured automatic Return Link policy and link modifiers. Crystal-portal arrival does not generate a landing platform, matching Legacy's external travel semantics. A normal Linking Book still needs Intra Linking for same-dimension travel.
When Immersive Portals is present, render-only, non-teleporting IP portal entities show the book's destination through both faces of each coplanar opening; individual crystal cells are combined into one shaped surface so the destination is not repeated tile by tile. The view's central camera is aligned to the same safe arrival position and yaw/pitch used by book travel (at standing eye height). Perspective still changes naturally as the observer moves in front of a live portal. Each live view is recessed inside the opening; the fallback portal texture is always hidden while IP is installed, and crystal blocks render after the IP view so the frame appears on top. Without IP, the animated Legacy portal texture remains visible. Rivencraft portal-block contact remains the travel authority. This is an optional compile-only integration. The first placement of a Descriptive Book can generate/load its Age to establish a view destination. If the optional view is absent after restart, the holder's periodic pass recreates it. #78 retains exhaustive multiplayer, protection, destination deletion, and chunk-ticket hardening. When Immersive Portals is present, render-only, non-teleporting IP portal entities show the book's destination through both faces of each coplanar opening; individual crystal cells are combined into one shaped surface so the destination is not repeated tile by tile. The view's central camera is aligned to the same safe arrival position and yaw/pitch used by book travel (at standing eye height). Perspective still changes naturally as the observer moves in front of a live portal. Each live view is recessed inside the opening; the animated fallback texture stays visible until the live view is established, and crystal blocks render after the IP view so the frame appears on top. Without IP, the animated Legacy portal texture remains visible. Rivencraft portal-block contact remains the travel authority. This is an optional compile-only integration. The first placement of a Descriptive Book can generate/load its Age to establish a view destination. If the optional view is absent after restart, the holder's maintenance pass recreates it. Portal contact uses IP's instant transfer only if the destination's 3×3 chunk area was already loaded; otherwise it uses vanilla Minecraft's dimension transfer.
Issue #78 hardening: placing each new portal cell fires a NeoForge entity-place protection event attributed to the player who inserted the book. If any cell is rejected, portal placement rolls back and the book remains with the player. Cells are placed without intermediate neighbor notifications so the frame validator cannot remove the first cells before the whole opening exists. Unattended maintenance never generates missing cells or tears down a saved portal during restart: block-change validation handles local frame damage, while the book remains retrievable. Portal owner positions are NBT integer arrays (not compound tags); the reader now matches that format, live-view state changes preserve the block entity's owner, and an intact saved surface with ownerless cells can recover its pointers from the adjacent book receptacle without placing new blocks. A missing neighboring chunk is treated as unknown, not as a broken frame. Portal cooldowns are per-player and session-only, never saved against a server tick counter that resets on restart. A Descriptive Book portal now searches for existing safe footing without creating a platform; Linking Books retain their intentionally floor-optional destination rule. Missing destination dimensions keep the book retrievable and remove any stale Immersive Portals view. Portal blocks and crystal frames are explicitly decay-protected. Book-GUI preview chunk loaders close on GUI exit, travel, logout, and server stop, with a five-minute fail-safe if a release packet is lost; crystal portals themselves add no Rivencraft chunk tickets.
Manual #78 matrix (use two players where noted): restart with an active portal and confirm the book, surface, and destination view return once its chunk loads; walk through it immediately and after 10 seconds without reinserting the book. Then unload/reload the Age and repeat. With destination chunks loaded, contact should transfer instantly; with them unloaded, it should use Minecraft's normal dimension transfer while the animated fallback remains visible until the live view is ready. Have both players enter the same portal, return through separately bound books, and confirm each cooldown is independent and neither loops immediately. Test a Descriptive Book destination with unsafe feet/head space and with no existing footing: it must choose another footed spot or refuse travel, never generate a platform. Deny a portal cell with a block-protection mod and confirm activation consumes no book and leaves no partial surface. Break one cell and confirm it does not silently reappear. Remove or unload the destination dimension and confirm no crash or stale live view, while the book can still be retrieved. Open a book preview with Immersive Portals, close it, and confirm its temporary chunk loader is released; an abandoned preview should expire within five minutes. Repeat with Immersive Portals absent to confirm the fallback texture and book travel.
Manual #77 check: build a 3×3 vertical Crystal Block ring, put the Receptacle on one frame crystal's outward face, then insert a bound Linking Book. The center should animate and link on contact; the book stays in the Receptacle. Confirm unbound books and incomplete frames do not consume items; empty-hand right-click retrieves the book and clears the surface. Repeat with a Descriptive Book and with Immersive Portals installed: the latter should show the destination live. Restart with the portal active and confirm the surface/view returns; break the support crystal and confirm book/portal cleanup. Manual #77 check: build a 3×3 vertical Crystal Block ring, put the Receptacle on one frame crystal's outward face, then insert a bound Linking Book. The center should animate and link on contact; the book stays in the Receptacle. Confirm unbound books and incomplete frames do not consume items; empty-hand right-click retrieves the book and clears the surface. Repeat with a Descriptive Book and with Immersive Portals installed: the latter should show the destination live. Restart with the portal active and confirm the surface/view returns; break the support crystal and confirm book/portal cleanup.
@@ -42,13 +46,13 @@ For a manual check, create a fresh surface Age and run `/rivencraft library near
Issue #75's comment calls this a despawning drop. The code differs subtly: [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L190-L224) explicitly cannot despawn, but takes one starvation damage every 10,000 ticks and damage while wet. It can eventually be destroyed if left unattended; a vanilla item entity's fixed five-minute timeout is not parity. A modern equivalent should retain the visible book and avoid silently deleting its Age identity at that timeout. Issue #75's comment calls this a despawning drop. The code differs subtly: [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L190-L224) explicitly cannot despawn, but takes one starvation damage every 10,000 ticks and damage while wet. It can eventually be destroyed if left unattended; a vanilla item entity's fixed five-minute timeout is not parity. A modern equivalent should retain the visible book and avoid silently deleting its Age identity at that timeout.
Legacy's travel path creates no automatic Return Link. Rivencraft currently defaults to `ON_CREATION` via `DescriptiveBookItem` and `descriptive_books.return_link_mode`. Per #75's comment, change the **new-config default** to `DISABLED` while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs must keep their explicit value. Handheld-book drop and Return Link default are tracked in [#85](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/85), not portal-frame logic. Legacy's travel path creates no automatic Return Link. Rivencraft now defaults to `DISABLED` in new server configs while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs keep their explicit value. Issue #85 adds the active-book drop only to hand-held Linking and Descriptive Book travel. On a successful link, the exact active stack becomes a dedicated `DroppedBookEntity` at the departure point, rendered with the same open `BookModel` and textures as the book stand; a rejected link restores it to the original hand. Stand and portal use leave their books in place. The entity has unlimited vanilla lifetime and loses one of its five item-health points every 10,000 loaded ticks, plus one per wet tick, matching Legacy's slow/wet decay. Unlike Legacy's custom entity, the modern book is picked up by ordinary contact, not sneaking with an empty hand, and remains subject to ordinary item hazards. Following is not yet a Rivencraft modifier, so no Following exception is exposed or silently simulated. Both hands can activate a book, whereas Legacy's explicit stack removal tested only its selected main-hand slot.
## Crafting, sounds, and progression ## Crafting, sounds, and progression
| Area | Legacy evidence and modern boundary | | Area | Legacy evidence and modern boundary |
| --- | --- | | --- | --- |
| Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft already has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, and Linking Book recipes. #79 adds only missing recipes/processes; preserve vanilla-Lectern conversion, noncraftable Blank Pages, and optional JEI. | | Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, Linking Book, and Receptacle recipes. #79 keeps these data-driven and adds recipe-book unlocks for Crystal Block, Receptacle, and Ink Vial. The Legacy potion-based ink variant is omitted: in 1.21.1 a simple potion ingredient does not return its glass bottle, while the existing water-bucket recipe returns its bucket. The Legacy Folder/Portfolio use Rivencraft's Notebook; the old two-part Desk is one item; the old Lectern/Book Stand recipes use a vanilla Lectern converted in-world by adding a Rivencraft book or page. Blank Pages remain noncraftable and Link Panels remain outside the Desk. JEI automatically displays the JSON crafting recipes; its existing optional categories cover Mixer, Desk, and Binder processes. The lectern interaction intentionally has no JEI guide. With Create installed, an optional Ponder scene attached to the Receptacle item demonstrates frame construction and book insertion; the core mod still runs without Create. |
| Sounds | [`Sounds`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/Sounds.java#L8-L26) registers pop, link, disarm, following, intra-link, fissure-link, portal-link, and meteor roar. The matching OGGs are in the local extraction. Rivencraft already carries link, fissure-link, and meteor roar. #80 should reuse and credit available Legacy files before creating alternatives. | | Sounds | [`Sounds`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/Sounds.java#L8-L26) registers pop, link, disarm, following, intra-link, fissure-link, portal-link, and meteor roar. The matching OGGs are in the local extraction. Rivencraft already carries link, fissure-link, and meteor roar. #80 should reuse and credit available Legacy files before creating alternatives. |
| Advancements | [Legacy advancement JSONs](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/advancements) have inventory milestones for a symbol page, unlinked Linking Book, and Age Book; a Writing Desk trigger; a safe Age-travel trigger; and a hidden Quinn trigger. #81 should use modern server-owned criteria. Quinn has no established Rivencraft equivalent and should be documented or deliberately omitted, not awarded on preview. | | Advancements | [Legacy advancement JSONs](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/advancements) have inventory milestones for a symbol page, unlinked Linking Book, and Age Book; a Writing Desk trigger; a safe Age-travel trigger; and a hidden Quinn trigger. #81 should use modern server-owned criteria. Quinn has no established Rivencraft equivalent and should be documented or deliberately omitted, not awarded on preview. |
+40
View File
@@ -0,0 +1,40 @@
# Milestone 7 advancements and discovery (#81)
Rivencraft's advancement tab follows the visible progression in Mystcraft
Legacy's `assets/mystcraft/advancements/`: Page, unwritten Linking Book,
Writing Desk writing, Age Book, and safe Age travel. Titles such as *The Ending
Has Not Yet Been Written*, *D'ni Ink*, and *The Art of Writing* reference Myst
without requiring any assets from that game. The Legacy hidden Quinn trigger
has no established Rivencraft action and is deliberately omitted.
`root`, `page`, and `linking_book` use vanilla server-side tick/inventory
criteria. `link_panel`, `copy_page`, `bind_age`, `open_portal`, and `enter_age`
use an impossible criterion awarded only after the server commits the
corresponding action. The Age-travel award requires a successful book or portal
link ending in a registered Rivencraft Age. Opening a book GUI, rendering a
destination preview, viewing a recipe, or merely loading an Age cannot award
it. Progress is stored by Minecraft per player and survives world restarts.
Vanilla recipe-book unlock advancements now cover the Writing Desk, Ink Mixer,
Book Binder, and Notebook, in addition to the existing Ink Vial, Crystal
Block, and Book Receptacle unlocks. The component-sensitive Linking Book
crafting recipe remains a special recipe: its Link Panel requirement and the
Writing Desk/Ink Mixer/Book Binder processes are explained by Rivencraft's
optional JEI integration. No recipe or advancement requires JEI, Create, or
Immersive Portals to be installed.
Manual Survival check: collect a Page and Linking Book; use the Ink Mixer to
take a Link Panel; copy a Page at the Writing Desk; bind a Descriptive Book;
enter its Age; activate a Crystal Portal. Confirm each advancement appears
only after the completed action, not from an output preview or GUI opening.
Repeat the actions with two players to confirm only the actor receives each
award. Restart an existing save and confirm completed progress remains, then
complete one previously unearned milestone. With JEI absent, the vanilla
recipe book should still reveal standard crafting recipes as their ingredients
are discovered; with JEI installed, the special writing guides should appear.
The automated Book Binder output-award scenario stalls in the GameTest storage
runner, so it is intentionally omitted under the project test policy. For its
manual check, open a ready Binder, observe that merely previewing the finished
book does not award *A Book of Ages*, then take the book and confirm the award.
Repeat by shift-clicking the result in a fresh Binder.
+53
View File
@@ -0,0 +1,53 @@
# Milestone 7 compatibility review (#82)
Reviewed 2026-10-19 for Minecraft 1.21.1. The project now compiles against
NeoForge 21.1.264 and advertises that as its minimum. The official
[NeoForge Maven metadata](https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml)
lists 21.1.264 as the newest 21.1 release. Its
[changelog](https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.264/neoforge-21.1.264-changelog.txt)
includes received split-packet size checks (21.1.260), network setup payload
direction/decoding limits (21.1.261), ad-hoc channel identifier limits
(21.1.262), and an initial attachment-sync fix (21.1.264). Those are concrete
reasons to evaluate an upgrade, but do not by themselves prove Rivencraft and
its optional integrations work on that loader.
**Decision: adopt 21.1.264 beginning with 0.6.6.2.** An override build
succeeded, but its GameTest server repeated its storage-progress display
without producing test results. That run was stopped; it is neither a pass nor
evidence of a NeoForge defect. The player subsequently passed an in-game
singleplayer test on 21.1.264 with the optional stack, then passed a dedicated
server test including Immersive Portals travel to a Rivencraft Age. The
dedicated server also reached the ready state with only the required mods
before the full optional stack was installed. Both modes used Rivencraft
0.6.6.1. The earlier 21.1.251 baseline passed 70 required GameTests for
release V0.6.6.0.
| Dependency | Project baseline | Newest checked 1.21.1 release | Follow-up |
| --- | --- | --- | --- |
| Dynamic Dimensions AR Edition (required) | 0.9.1 | 0.9.1 | No newer release found on [CurseForge](https://www.curseforge.com/minecraft/mc-mods/dynamicdimension-ar-edition/files/all). |
| Bad Packets (required) | neo-0.8.2 | neo-0.8.2 | No newer matching [Modrinth](https://modrinth.com/mod/badpackets/versions) release found. |
| JEI (optional) | 19.57.0.449 at #82 review | 19.69.0.457 beta | Updated in #86 after client and dedicated GameTest passes; see [JEI review](MILESTONE_7_JEI_REVIEW.md). |
| Create (optional) | 6.0.10 | 6.0.10 | No newer matching [Modrinth](https://modrinth.com/mod/create/versions) release found. Its jar bundles Ponder 1.0.82. |
| Ponder (bundled with Create) | 1.0.82 | 1.0.87 library artifact | [Maven](https://maven.createmod.net/net/createmod/ponder/ponder-neoforge/maven-metadata.xml) has a newer artifact, but Create 6.0.10 still bundles 1.0.82. Do not treat this as a separately installed Create update. |
| Immersive Portals (optional) | 6.0.7 | 6.0.7 | No newer matching [Modrinth](https://modrinth.com/mod/immersive-portals-neoforge/versions) release found. |
| TerraBlender (optional) | 4.1.0.8 | 4.1.0.8 | No newer matching [Modrinth](https://modrinth.com/mod/terrablender/versions) release found. |
| Serene Seasons (optional) | 10.1.0.8 at #82 review | 10.1.0.9 | Updated in #88 after the player's full seasonal matrix passed; see [Serene review](MILESTONE_7_SERENE_REVIEW.md). |
| GlitchCore (Serene dependency) | 2.1.0.2 | 2.1.0.2 | No newer matching [Modrinth](https://modrinth.com/mod/glitchcore/versions) release found. |
| Biomes O' Plenty (TerraBlender test addon) | 21.1.0.14 in user's Prism instance | 21.1.0.14 | No newer matching [Modrinth](https://modrinth.com/mod/biomes-o-plenty/versions) release found. |
The Modrinth version comparisons above were filtered to **NeoForge and
Minecraft 1.21.1** on 2026-10-19. Create's bundled Ponder version was read
from `META-INF/jarjar/metadata.json` in `create-1.21.1-6.0.10.jar`; the
separate library-artifact comparison came from Create's Maven. Dynamic
Dimensions was checked on CurseForge. Versions for other Minecraft lines and
similarly named mods were excluded.
The full dedicated test stack contained Create/Ponder, Immersive Portals, JEI,
TerraBlender with Biomes O' Plenty, and Serene Seasons with GlitchCore. The
server loaded a new `rivencraft:age/test`, and its shutdown log confirms that
all Overworld, Nether, End, and Age chunks were saved. The player explicitly
approved 21.1.264 after that test. This does not independently certify every
optional combination, the LEGACY preview fallback on a client without Create
and Immersive Portals, or a full restart/rejoin cycle; keep those in the
regression checklist. Serene's GameTest mock-player network limitation still
requires real-client checks for seasonal behavior.
+41
View File
@@ -0,0 +1,41 @@
# JEI compatibility review (#86)
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
now targets JEI 19.69.0.457 after the client's in-game acceptance check. JEI
remains a `compileOnly` API and an opt-in
`localRuntime` dependency for development; the published mod has no required
JEI dependency.
The [official 1.21.1 NeoForge files](https://www.curseforge.com/minecraft/mc-mods/jei/files/all?page=1&pageSize=20&version=1.21.1)
list 19.69.0.457 (beta, 2026-10-20) after 19.64.0.455 (release, 2026-10-16).
The [19.69.0.457 Modrinth changelog](https://modrinth.com/mod/jei/versions?g=1.21.1&l=neoforge)
has a published-jar bytecode compatibility check. Its preceding 19.69.0.456
build changes ingredient search to Lucene and adds a runtime ingredient-search
refresh API. This makes page search a required client check, even though the
current Rivencraft plugin still compiles against the 457 API. The new Maven
runtime artifact is much larger (about 7 MB versus 2.3 MB for 455); a successful
compile alone is not evidence that its runtime plugin works.
Checks performed before changing the pinned dependency:
| Configuration | Result |
| --- | --- |
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true build` | Passed; existing JEI API uses compile with four deprecation warnings and no errors. |
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true runGameTestServer` | Passed; dedicated GameTest server loaded JEI 19.69.0.457 and all 74 required tests passed. |
| `./gradlew -Pjei_version=19.69.0.457 runGameTestServer` | Passed without JEI; all 74 required tests passed. |
| Prism client with JEI 19.69.0.457 and Rivencraft 0.6.6.6 | Client joined an integrated world; JEI registered Rivencraft extra ingredients and started without a JEI-plugin exception. The player reported an in-game pass for guide display, page search, and recipe visibility. |
On first 457 launch, JEI migrated its old `lowMemorySlowSearchEnabled` config
key and saved a backup of the original `jei-client.ini`; that warning was
handled by JEI and did not block startup. No Rivencraft config was changed.
The in-game acceptance report covers the visible JEI integration. A dedicated
GameTest server proves no server-side startup failure but does not exercise the
JEI GUI; keep page search, recipes, blank-Page hiding, and the Ink Mixer,
Writing Desk, and Book Binder guides in future upgrade checks. JEI is not
bundled into or required by the published Rivencraft jar.
The final pinned `0.6.6.7` build passed. A repeat GameTest run from the pinned
configuration stalled during test-world preparation before running any cases;
it was stopped. The two successful pre-pin runs above used the same JEI version
and code, with the dependency supplied by a Gradle property override.
+31
View File
@@ -0,0 +1,31 @@
# Serene Seasons 10.1.0.9 compatibility review (#88)
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
now compiles against [Serene Seasons 10.1.0.9](https://modrinth.com/mod/serene-seasons/version/pHEgQQUE)
with [GlitchCore 2.1.0.2](https://modrinth.com/mod/glitchcore/version/S2TfWrZR).
Both remain optional. The Serene update fixes a precipitation-mixin recursion;
it does not introduce a new dynamic-dimension API.
The 10.1.0.8 and 10.1.0.9 bytecode for Rivencraft's version-sensitive
`SeasonsConfig`, `SeasonHandler`, `SeasonSavedData`, and `SeasonTime` integration
classes is identical. In particular, the dimension whitelist field, per-level
saved season clock, and season update method have unchanged signatures and
implementations. The adapter still writes the whitelist through GlitchCore's
config API and sends Rivencraft's whitelist update to already connected clients.
Verification:
| Check | Result |
| --- | --- |
| Build against 10.1.0.9 and GlitchCore 2.1.0.2 | Passed. |
| Real Prism client with Serene 10.1.0.9, GlitchCore 2.1.0.2, and Rivencraft 0.6.6.7 | Both mods loaded and the player joined an Age. |
| No Seasons, normal Seasons, and all four Eternal modes in new and saved Ages | Player reported the full in-game matrix passed. |
| Reconnect, restart, and Overworld independence | Player reported the full in-game matrix passed. |
| Isolated dedicated-server startup without Serene/GlitchCore | Reached `Done` on port 25568 with only required runtime mods, then saved and stopped. |
| Isolated dedicated-server startup with Serene/GlitchCore | Reached `Done` on port 25569 with 10.1.0.9/2.1.0.2, then saved and stopped. |
Serene's sync packet is incompatible with GameTest mock players, so its
in-game behavior must be checked with a real client and server. The seasonal
test path in [SERENE_SEASONS_COMPATIBILITY.md](SERENE_SEASONS_COMPATIBILITY.md)
remains the regression checklist for future upgrades. Rivencraft does not
bundle or require Serene Seasons or GlitchCore.
+63
View File
@@ -0,0 +1,63 @@
# Milestone 7 visual and audio polish (#80)
The source is the local Mystcraft 0.13.7.06 extraction under
`/home/dnorberg/Documents/mystcraft-src/mystcraft-1.12.2-0.13.7.06/`.
Reused textures and OGG files are recorded in [asset provenance](ASSET_PROVENANCE.md).
The `dev images/book binder.png` and `dev images/ink mixer.png` screenshots
are the Legacy model references used for this slice.
## Legacy comparison and modern boundary
- The Crystal Block, Book Receptacle, and 16×512 portal animation already use
Legacy sprites. The Receptacle's 16×16×6 model already matches the Legacy
slab. The fallback portal now uses Legacy's four-pixel-deep, double-faced
`linkportal_ns.json` geometry, with the original animation; it declares a
translucent render layer and portal particle texture. The block remains
non-occluding. When Immersive Portals supplies a live view, the fallback
model is hidden, while the crystal frame is drawn over the view.
- The Age arrival platform now reuses the same Legacy crystal tile as Crystal
Block. It is an opaque, occluding full block, while retaining its empty loot
table and unobtainable pick-block behavior. It has glass-like 0.3 hardness.
The old cloud sprite is unused and removed.
- The Book Binder now includes the two inner braces and central press/book
piece omitted by the earlier model, using the existing byte-identical
Legacy textures. The Ink Mixer uses Legacy's `inkwell_model.json` geometry
and four original inkwell textures. A thin white paper surface replaces the
black paper appearance from the reference; the old simplified mixer tiles
are no longer used. The half-alpha glass atlas is intentionally rendered on
a cutout layer; its more opaque look passed the player's in-game check.
- Legacy's link listener selects Disarm before a custom portal sound, then
Following, Intra Linking, and ordinary Link. Rivencraft has no Following
modifier yet, so it selects Disarm, portal, Intra Linking, or ordinary Link
in that order. The Disarm and Intra OGGs are byte-identical Legacy copies.
- Legacy sends 50 small neutral link particles in roughly a one-by-two-block
region at both departure and arrival. Its fixed-function particle class is
not portable to 1.21.1. Rivencraft uses a one-shot burst of 50 vanilla Cloud
particles at both ends, emitted only after successful server-authoritative
travel. There is no continuous portal-particle loop in the inspected Legacy
`BlockLinkPortal` class, so the portal surface itself adds none.
- The Legacy `textures/entity/linkbook.png` and `linking/pop.ogg` are inspected
but not copied in this slice. The custom dropped Linkbook entity is part of
#85; adding its assets before the entity exists would leave unused resources.
Library walls, shelves, chest, and webs use vanilla blocks in the template,
so there are no separate library art files to port.
## Manual visual/audio check
Use the same bound book in a held use and a Crystal Portal. Confirm the link
particles and audio play at both ends, and the portal landing matches the book.
Repeat with Disarm and Intra Linking panels to check the distinct sounds.
Compare the fallback portal, crystal frame, and Receptacle with Immersive
Portals absent; verify the animated surface is visible from both sides and
does not expose caves or other geometry through solid blocks. With Immersive
Portals installed, confirm the live view replaces only the fallback surface,
the frame draws in front, and no fallback texture covers the live view.
Create/Ponder is optional and must not affect either rendering path.
Check a freshly generated arrival platform from above and below: the crystal
tile should be opaque, break about as quickly as glass, and drop no block item.
Compare the Book Binder press and Ink Mixer cup/glass against the `dev images`
references; the mixer paper should appear white, and neither block should
show missing-texture checkerboards or see-through-world artifacts.
The player reported that the in-game visual and audio checks pass on 2026-10-19,
including acceptance of the cutout glass appearance and current block sounds.
+1 -1
View File
@@ -45,7 +45,7 @@ being captured in the preview.
## Supported combinations and upstream limits ## Supported combinations and upstream limits
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.251, Create The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.264, Create
6.0.10, and Immersive Portals 6.0.7. Core Age generation and the `LEGACY` 6.0.10, and Immersive Portals 6.0.7. Core Age generation and the `LEGACY`
backend run without Create or Immersive Portals; the headless GameTest server backend run without Create or Immersive Portals; the headless GameTest server
uses that dependency-free path. uses that dependency-free path.
+4 -4
View File
@@ -18,11 +18,11 @@ minecraft_version=1.21.1
# as they do not follow standard versioning conventions. # as they do not follow standard versioning conventions.
minecraft_version_range=[1.21.1] minecraft_version_range=[1.21.1]
# The Neo version must agree with the Minecraft version to get a valid artifact # The Neo version must agree with the Minecraft version to get a valid artifact
neo_version=21.1.251 neo_version=21.1.264
ponder_version=1.0.82 ponder_version=1.0.82
dynamic_dimensions_file_id=7707789 dynamic_dimensions_file_id=7707789
badpackets_version=0.8.2 badpackets_version=0.8.2
jei_version=19.57.0.449 jei_version=19.69.0.457
# The loader version range can only use the major version of FML as bounds # The loader version range can only use the major version of FML as bounds
loader_version_range=[1,) loader_version_range=[1,)
@@ -36,10 +36,10 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.6.2.5 mod_version=0.6.7.0
immersive_portals_version=6.0.7 immersive_portals_version=6.0.7
terrablender_version=4.1.0.8 terrablender_version=4.1.0.8
serene_seasons_version_id=B6nTjh4b serene_seasons_version_id=pHEgQQUE
glitchcore_version_id=S2TfWrZR glitchcore_version_id=S2TfWrZR
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources. # This should match the base package used for the mod sources.
@@ -54,6 +54,7 @@ public final class Rivencraft {
ModBiomeSources.register(modEventBus); ModBiomeSources.register(modEventBus);
ModChunkGenerators.register(modEventBus); ModChunkGenerators.register(modEventBus);
ModBlocks.register(modEventBus); ModBlocks.register(modEventBus);
net.dnorberg.rivencraft.registry.ModEntities.register(modEventBus);
ModBlockEntities.register(modEventBus); ModBlockEntities.register(modEventBus);
ModItems.register(modEventBus); ModItems.register(modEventBus);
ModMenus.register(modEventBus); ModMenus.register(modEventBus);
@@ -76,6 +77,7 @@ public final class Rivencraft {
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerLoggedIn); NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerLoggedIn);
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerChangedDimension); NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerChangedDimension);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut); NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerTickPost);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping); NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping); NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping); NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.advancement;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
/** Awards action-only progression after the server has committed the action. */
public final class ProgressionAdvancements {
public static final String LINK_PANEL = "link_panel";
public static final String COPY_PAGE = "copy_page";
public static final String BIND_AGE = "bind_age";
public static final String OPEN_PORTAL = "open_portal";
public static final String ENTER_AGE = "enter_age";
private ProgressionAdvancements() {}
public static void award(ServerPlayer player, String path) {
var advancement = player.getServer().getAdvancements().get(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
if (advancement != null) player.getAdvancements().award(advancement, "completed");
}
public static void awardAgeTravel(ServerPlayer player) {
if (AgeRegistrySavedData.get(player.serverLevel()).findByDimension(
player.serverLevel().dimension().location()).isPresent()) {
award(player, ENTER_AGE);
}
}
}
@@ -1,5 +1,6 @@
package net.dnorberg.rivencraft.block; package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import com.mojang.serialization.MapCodec; import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
@@ -96,7 +97,11 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
LinkTarget destination = stack.is(ModItems.LINKING_BOOK.get()) LinkTarget destination = stack.is(ModItems.LINKING_BOOK.get())
? stack.get(ModDataComponents.LINK_TARGET.get()) ? stack.get(ModDataComponents.LINK_TARGET.get())
: player instanceof ServerPlayer serverPlayer : player instanceof ServerPlayer serverPlayer
? AgeDimensionService.resolve(serverPlayer, stack.get(ModDataComponents.AGE_REFERENCE.get())).orElse(null) ? AgeDimensionService.resolve(serverPlayer, stack.get(ModDataComponents.AGE_REFERENCE.get()),
net.dnorberg.rivencraft.writing.OrderedWriting.EMPTY,
!stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(),
net.dnorberg.rivencraft.book.LinkProperties.NONE)
.has(net.dnorberg.rivencraft.book.LinkProperties.DISABLE_PLATFORM)).orElse(null)
: null; : null;
if (destination == null || server.getServer().getLevel(destination.location().dimension()) == null) { if (destination == null || server.getServer().getLevel(destination.location().dimension()) == null) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true); player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
@@ -104,14 +109,21 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
} }
holder.setBook(stack.copyWithCount(1)); holder.setBook(stack.copyWithCount(1));
holder.setDestination(destination); holder.setDestination(destination);
if (!CrystalPortalNetwork.activate(server, pos, plan)) { if (!CrystalPortalNetwork.activate(server, pos, plan, player)) {
holder.takeBook(); holder.takeBook();
return ItemInteractionResult.FAIL; return ItemInteractionResult.FAIL;
} }
if (!player.getAbilities().instabuild) stack.shrink(1); if (!player.getAbilities().instabuild) stack.shrink(1);
if (net.neoforged.fml.ModList.get().isLoaded("immersive_portals_core")) if (net.neoforged.fml.ModList.get().isLoaded("immersive_portals_core")) {
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(server, pos, plan, destination); net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(server, pos, plan, destination,
holder.getBook().is(ModItems.DESCRIPTIVE_BOOK.get()));
CrystalPortalNetwork.setLiveView(server, plan,
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.complete(
server, pos, plan, destination));
}
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1); level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.OPEN_PORTAL);
return ItemInteractionResult.SUCCESS; return ItemInteractionResult.SUCCESS;
} }
@@ -132,7 +144,8 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor, @Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
BlockPos neighborPos, boolean moved) { BlockPos neighborPos, boolean moved) {
super.neighborChanged(state, level, pos, neighbor, neighborPos, moved); super.neighborChanged(state, level, pos, neighbor, neighborPos, moved);
if (!level.isClientSide && !level.getBlockState(pos.relative(state.getValue(FACING).getOpposite())) BlockPos support = pos.relative(state.getValue(FACING).getOpposite());
if (!level.isClientSide && level.hasChunkAt(support) && !level.getBlockState(support)
.is(ModBlocks.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true); .is(ModBlocks.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true);
} }
@@ -1,14 +1,12 @@
package net.dnorberg.rivencraft.block; package net.dnorberg.rivencraft.block;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.LevelReader; import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
/** Solid footing for Age arrivals, rendered as a translucent cloud. */ /** Solid crystal-textured footing for Age arrivals that cannot be collected. */
public final class CloudPlatformBlock extends Block { public final class CloudPlatformBlock extends Block {
public CloudPlatformBlock(Properties properties) { public CloudPlatformBlock(Properties properties) {
super(properties); super(properties);
@@ -18,19 +16,4 @@ public final class CloudPlatformBlock extends Block {
public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) { public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) {
return ItemStack.EMPTY; return ItemStack.EMPTY;
} }
@Override
protected boolean skipRendering(BlockState state, BlockState adjacent, Direction side) {
return adjacent.is(this) || super.skipRendering(state, adjacent, side);
}
@Override
protected boolean propagatesSkylightDown(BlockState state, BlockGetter level, BlockPos pos) {
return true;
}
@Override
protected int getLightBlock(BlockState state, BlockGetter level, BlockPos pos) {
return 0;
}
} }
@@ -3,15 +3,13 @@ package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec; import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.dnorberg.rivencraft.portal.PortalTravelCooldown; import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction; import net.minecraft.core.Direction;
import net.minecraft.nbt.Tag;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.RandomSource; import net.minecraft.util.RandomSource;
@@ -35,7 +33,6 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
// Retained so portal blocks already saved with this state still deserialize. // Retained so portal blocks already saved with this state still deserialize.
public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view"); public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view");
public static final EnumProperty<Direction.Axis> AXIS = EnumProperty.create("axis", Direction.Axis.class); public static final EnumProperty<Direction.Axis> AXIS = EnumProperty.create("axis", Direction.Axis.class);
private static final String COOLDOWN = "RivencraftCrystalPortalCooldown";
public CrystalPortalBlock(Properties properties) { public CrystalPortalBlock(Properties properties) {
super(properties); super(properties);
@@ -46,10 +43,9 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
builder.add(LIVE_VIEW, AXIS); builder.add(LIVE_VIEW, AXIS);
} }
@Override protected RenderShape getRenderShape(BlockState state) { @Override protected RenderShape getRenderShape(BlockState state) {
// The live-view flag can lag behind portal-entity creation or fail to // Keep the animated fallback until the optional destination view has
// synchronize for an existing frame. Never draw the fallback texture // actually been established, including after a world restart.
// when Immersive Portals owns the view for this installation. return ModList.get().isLoaded("immersive_portals_core") && state.getValue(LIVE_VIEW)
return ModList.get().isLoaded("immersive_portals_core")
? RenderShape.INVISIBLE : RenderShape.MODEL; ? RenderShape.INVISIBLE : RenderShape.MODEL;
} }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
@@ -60,35 +56,30 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) { @Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) {
if (!(level instanceof ServerLevel server) || !(entity instanceof ServerPlayer player)) return; if (!(level instanceof ServerLevel server) || !(entity instanceof ServerPlayer player)) return;
long now = server.getServer().getTickCount(); long now = server.getServer().getTickCount();
long previous = player.getPersistentData().contains(COOLDOWN, Tag.TAG_LONG) if (PortalTravelCooldown.blocked(player, now)) return;
? player.getPersistentData().getLong(COOLDOWN) : -1;
if (previous > now) {
player.getPersistentData().remove(COOLDOWN);
previous = -1;
}
if (PortalTravelCooldown.blocked(now, previous)) return;
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle()) || portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return; || !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
ItemStack book = holder.getBook(); ItemStack book = holder.getBook();
if (book.isEmpty()) return; if (book.isEmpty()) return;
player.getPersistentData().putLong(COOLDOWN, now); PortalTravelCooldown.mark(player, now);
if (book.is(ModItems.LINKING_BOOK.get())) LinkingBookItem.usePortalBook(player, book); PortalTravelQueue.enqueue(player, server, pos, portal.receptacle(), book, now);
else if (book.is(ModItems.DESCRIPTIVE_BOOK.get())) DescriptiveBookItem.usePortalBook(player, book);
} }
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor, @Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
BlockPos neighborPos, boolean moved) { BlockPos neighborPos, boolean moved) {
super.neighborChanged(state, level, pos, neighbor, neighborPos, moved); super.neighborChanged(state, level, pos, neighbor, neighborPos, moved);
if (level instanceof ServerLevel server) server.scheduleTick(pos, this, 2); if (!(level instanceof ServerLevel server)
|| !(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| portal.receptacle() == null) return;
// Like a vanilla portal, validate when the frame changes, not on a
// repeating timer that can race block entities loading after restart.
if (CrystalPortalNetwork.cellStatus(server, pos, portal.receptacle())
== CrystalPortalNetwork.CellStatus.INVALID) server.removeBlock(pos, false);
} }
@Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) { @Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) // Ignore validation ticks saved by older builds. Frame edits are
|| portal.receptacle() == null || !CrystalPortalNetwork.validCell(level, pos, portal.receptacle())) { // handled by neighborChanged, and the receptacle maintains the view.
level.removeBlock(pos, false);
return;
}
level.scheduleTick(pos, this, 100);
} }
} }
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.phys.BlockHitResult;
public final class LinkModifierBlock extends BaseEntityBlock {
public static final MapCodec<LinkModifierBlock> CODEC = simpleCodec(LinkModifierBlock::new);
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
public LinkModifierBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block,
BlockState> builder) { builder.add(FACING); }
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new LinkModifierBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new LinkModifierMenu(id, inventory, modifier),
Component.translatable("container.rivencraft.link_modifier")),
buffer -> buffer.writeBlockPos(pos));
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
@Override protected void onRemove(BlockState state, Level level, BlockPos pos,
BlockState replacement, boolean moving) {
if (!state.is(replacement.getBlock()) && !level.isClientSide
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
Containers.dropContents(level, pos, modifier);
}
super.onRemove(state, level, pos, replacement, moving);
}
}
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents; import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.WritingGrammar; import net.dnorberg.rivencraft.writing.WritingGrammar;
@@ -96,7 +97,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
case COVER -> validCover(stack); case COVER -> validCover(stack);
case LINK_PANEL -> stack.is(ModItems.PAGE.get()) case LINK_PANEL -> stack.is(ModItems.PAGE.get())
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL; && PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL;
case NOTEBOOK -> stack.is(ModItems.NOTEBOOK.get()); case NOTEBOOK -> CollationFolderItem.isCollection(stack);
default -> slot >= FIRST_PAGE && slot < SIZE && validPage(stack); default -> slot >= FIRST_PAGE && slot < SIZE && validPage(stack);
}; };
} }
@@ -296,7 +297,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
/** Import a Notebook from either its stored slot or the player's cursor. */ /** Import a Notebook from either its stored slot or the player's cursor. */
public ItemStack importNotebookFromStack(ItemStack notebook, int index) { public ItemStack importNotebookFromStack(ItemStack notebook, int index) {
if (!notebook.is(ModItems.NOTEBOOK.get()) || notebook.getCount() != 1) return ItemStack.EMPTY; if (!CollationFolderItem.isCollection(notebook) || notebook.getCount() != 1) return ItemStack.EMPTY;
List<ItemStack> pages = notebook.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages(); List<ItemStack> pages = notebook.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
if (pages.isEmpty()) return ItemStack.EMPTY; if (pages.isEmpty()) return ItemStack.EMPTY;
int end = nextPageIndex(); int end = nextPageIndex();
@@ -22,6 +22,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
private static final String DESTINATION_TAG = "Destination"; private static final String DESTINATION_TAG = "Destination";
private ItemStack book = ItemStack.EMPTY; private ItemStack book = ItemStack.EMPTY;
private LinkTarget destination; private LinkTarget destination;
private boolean restoreView = true;
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) { public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.BOOK_RECEPTACLE.get(), pos, state); super(ModBlockEntities.BOOK_RECEPTACLE.get(), pos, state);
@@ -62,6 +63,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
: ItemStack.EMPTY; : ItemStack.EMPTY;
destination = tag.contains(DESTINATION_TAG) destination = tag.contains(DESTINATION_TAG)
? LinkTarget.CODEC.parse(NbtOps.INSTANCE, tag.get(DESTINATION_TAG)).result().orElse(null) : null; ? LinkTarget.CODEC.parse(NbtOps.INSTANCE, tag.get(DESTINATION_TAG)).result().orElse(null) : null;
restoreView = true;
} }
@Override @Override
@@ -83,13 +85,38 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state, public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state,
BookReceptacleBlockEntity holder) { BookReceptacleBlockEntity holder) {
if (!(world instanceof ServerLevel level)) return; if (!(world instanceof ServerLevel level)) return;
if (level.getGameTime() % 200 != 0 || holder.book.isEmpty()) return; if (holder.book.isEmpty() || (!holder.restoreView && level.getGameTime() % 200 != 0)) return;
CrystalPortalNetwork.reconcile(level, pos); // Startup maintenance must never turn a saved portal off while its
if (holder.destination == null || !ModList.get().isLoaded("immersive_portals_core")) return; // chunks and block entities are still arriving. Structural edits are
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.exists(level, pos)) { // validated by the portal blocks' neighbor updates instead.
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, pos); if (holder.restoreView) CrystalPortalNetwork.recoverSavedOwners(level, pos);
if (plan.valid()) net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate( CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, pos);
level, pos, plan, holder.destination); if (!plan.valid()) return;
if (!ModList.get().isLoaded("immersive_portals_core")) {
holder.restoreView = false;
return;
} }
if (holder.restoreView) CrystalPortalNetwork.setLiveView(level, plan, false);
holder.restoreView = false;
if (holder.destination == null) {
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.remove(level, pos);
CrystalPortalNetwork.setLiveView(level, plan, false);
return;
}
if (level.getServer().getLevel(holder.destination.location().dimension()) == null) {
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.remove(level, pos);
CrystalPortalNetwork.setLiveView(level, plan, false);
return;
}
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.complete(
level, pos, plan, holder.destination)) {
CrystalPortalNetwork.setLiveView(level, plan, false);
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(
level, pos, plan, holder.destination,
holder.book.is(net.dnorberg.rivencraft.registry.ModItems.DESCRIPTIVE_BOOK.get()));
}
CrystalPortalNetwork.setLiveView(level, plan,
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.complete(
level, pos, plan, holder.destination));
} }
} }
@@ -31,8 +31,7 @@ public final class CrystalPortalBlockEntity extends BlockEntity {
@Override @Override
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries); super.loadAdditional(tag, registries);
receptacle = tag.contains(SOURCE_TAG, CompoundTag.TAG_COMPOUND) receptacle = net.minecraft.nbt.NbtUtils.readBlockPos(tag, SOURCE_TAG).orElse(null);
? net.minecraft.nbt.NbtUtils.readBlockPos(tag, SOURCE_TAG).orElse(null) : null;
} }
@Override @Override
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** One persisted book slot; edits are performed by the server menu. */
public final class LinkModifierBlockEntity extends BlockEntity implements Container {
private NonNullList<ItemStack> items = NonNullList.withSize(1, ItemStack.EMPTY);
public LinkModifierBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.LINK_MODIFIER.get(), pos, state);
}
@Override public int getContainerSize() { return 1; }
@Override public boolean isEmpty() { return items.getFirst().isEmpty(); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) {
ItemStack removed = ContainerHelper.takeItem(items, slot);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack.copyWithCount(Math.min(1, stack.getCount())));
setChanged();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return slot == 0 && (stack.is(ModItems.LINKING_BOOK.get())
|| stack.is(ModItems.DESCRIPTIVE_BOOK.get()));
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public void clearContent() {
items = NonNullList.withSize(1, ItemStack.EMPTY);
setChanged();
}
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
items = NonNullList.withSize(1, ItemStack.EMPTY);
ContainerHelper.loadAllItems(tag, items, registries);
}
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, items, registries);
}
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.item.InkVialItem; import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.InkVialExchange; import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.WritingInkService; import net.dnorberg.rivencraft.writing.WritingInkService;
import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.PageContent;
@@ -87,7 +88,7 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
worldPosition.getZ() + 0.5) <= 64.0; worldPosition.getZ() + 0.5) <= 64.0;
} }
@Override public boolean canPlaceItem(int slot, ItemStack stack) { @Override public boolean canPlaceItem(int slot, ItemStack stack) {
if (slot < COLLECTIONS) return stack.is(ModItems.NOTEBOOK.get()) if (slot < COLLECTIONS) return CollationFolderItem.isCollection(stack)
&& stack.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY) && stack.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) .pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|| stack.is(ModItems.PAGE.get()) || stack.is(ModItems.PAGE.get())
@@ -118,4 +118,10 @@ public record AgeDefinition(
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode, createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode,
randomFeatures, instability, instabilityPlan); randomFeatures, instability, instabilityPlan);
} }
/** A frozen authored profile takes precedence over the profile ID on old books. */
public boolean caveTerrain() {
return AgeBiomeSelection.isCave(plan == null
? AgeRuntimeProfiles.resolve(generationProfile) : plan.runtimeProfile());
}
} }
@@ -0,0 +1,56 @@
package net.dnorberg.rivencraft.book;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.AtomicMoveNotSupportedException;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.storage.LevelResource;
/** Retires an exact Age and moves its unloaded region data out of the live world. */
public final class AgeDeletionService {
public enum Result { RETIRED, ALREADY_DEAD, OCCUPIED, UNLOAD_FAILED, STORAGE_FAILED }
private AgeDeletionService() { }
public static Result retire(MinecraftServer server, AgeDefinition age) {
AgeRegistrySavedData registry = AgeRegistrySavedData.get(server.overworld());
if (registry.isDead(age.id())) return Result.ALREADY_DEAD;
var level = server.getLevel(AgeDimensionService.dimensionKey(age));
if (level != null && !level.players().isEmpty()) return Result.OCCUPIED;
if (AgeDimensionService.unload(server, age) == AgeDimensionService.UnloadResult.FAILED)
return Result.UNLOAD_FAILED;
Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
Path ageRoot = world.resolve("dimensions/rivencraft/age").normalize();
Path data = world.resolve("dimensions")
.resolve(age.dimensionId().getNamespace())
.resolve(age.dimensionId().getPath()).normalize();
// Exact Rivencraft Age only; never move the shared age/ directory.
if (!data.startsWith(ageRoot) || data.equals(ageRoot)
|| !"rivencraft".equals(age.dimensionId().getNamespace())
|| !age.dimensionId().getPath().startsWith("age/")) return Result.STORAGE_FAILED;
try {
if (Files.isSymbolicLink(world.resolve("dimensions"))
|| Files.isSymbolicLink(world.resolve("dimensions/rivencraft"))
|| Files.isSymbolicLink(ageRoot) || Files.isSymbolicLink(data)) return Result.STORAGE_FAILED;
if (Files.exists(data, LinkOption.NOFOLLOW_LINKS)) {
Path archive = world.getParent().resolve(world.getFileName() + "-retired-ages")
.resolve(age.id().toString());
if (Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) return Result.STORAGE_FAILED;
Files.createDirectories(archive.getParent());
try { Files.move(data, archive, StandardCopyOption.ATOMIC_MOVE); }
catch (AtomicMoveNotSupportedException exception) { Files.move(data, archive); }
Rivencraft.LOGGER.warn("Retired Age {} data moved from {} to {}", age.id(), data, archive);
}
} catch (IOException exception) {
Rivencraft.LOGGER.error("Cannot archive data for Age {}", age.id(), exception);
return Result.STORAGE_FAILED;
}
registry.markDead(age.id());
return Result.RETIRED;
}
}
@@ -74,6 +74,7 @@ public final class AgeDimensionService {
DynamicDimensionLoadCallback.register((server, loader) -> { DynamicDimensionLoadCallback.register((server, loader) -> {
ServerLevel overworld = server.overworld(); ServerLevel overworld = server.overworld();
for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) { for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) {
if (AgeRegistrySavedData.get(overworld).isDead(age.id())) continue;
if (!canLoadProfile(server, age)) continue; if (!canLoadProfile(server, age)) continue;
ServerLevel level = loader.loadDynamicDimension( ServerLevel level = loader.loadDynamicDimension(
age.dimensionId(), age.dimensionId(),
@@ -95,7 +96,13 @@ public final class AgeDimensionService {
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference, public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
OrderedWriting writing) { OrderedWriting writing) {
return resolve(player, reference, writing, true);
}
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
OrderedWriting writing, boolean generatePlatform) {
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(player.serverLevel(), reference, writing); AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(player.serverLevel(), reference, writing);
if (AgeRegistrySavedData.get(player.serverLevel()).isDead(definition.id())) return Optional.empty();
ServerLevel level = ensureLoaded(player.getServer(), definition); ServerLevel level = ensureLoaded(player.getServer(), definition);
if (level == null) { if (level == null) {
return Optional.empty(); return Optional.empty();
@@ -104,7 +111,8 @@ public final class AgeDimensionService {
BlockPos spawn = definition.spawn(); BlockPos spawn = definition.spawn();
if (spawn == null) { if (spawn == null) {
applyInitialCelestialPhase(level, definition); applyInitialCelestialPhase(level, definition);
spawn = createSafeSpawn(level, definition); spawn = createSafeSpawn(level, definition, generatePlatform);
if (spawn == null) return Optional.empty();
if (AgeFeatureSelection.resolved(definition.plan()).stream() if (AgeFeatureSelection.resolved(definition.plan()).stream()
.anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES)) .anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES))
|| definition.randomFeatures().stream() || definition.randomFeatures().stream()
@@ -122,6 +130,7 @@ public final class AgeDimensionService {
} }
public static ServerLevel ensureLoaded(MinecraftServer server, AgeDefinition definition) { public static ServerLevel ensureLoaded(MinecraftServer server, AgeDefinition definition) {
if (AgeRegistrySavedData.get(server.overworld()).isDead(definition.id())) return null;
ResourceKey<Level> key = dimensionKey(definition); ResourceKey<Level> key = dimensionKey(definition);
ServerLevel existing = server.getLevel(key); ServerLevel existing = server.getLevel(key);
if (existing != null) { if (existing != null) {
@@ -170,6 +179,10 @@ public final class AgeDimensionService {
} }
private static void applyCelestialClock(ServerLevel level, AgeDefinition age) { private static void applyCelestialClock(ServerLevel level, AgeDefinition age) {
if (age.caveTerrain()) {
level.setDayTimePerTick(1.0F);
return;
}
if (age.traits().hasStoppedCelestialCycle()) { if (age.traits().hasStoppedCelestialCycle()) {
// NeoForge rejects an exact zero rate. The smallest positive float // NeoForge rejects an exact zero rate. The smallest positive float
// never advances the integer clock during a practical world // never advances the integer clock during a practical world
@@ -198,6 +211,7 @@ public final class AgeDimensionService {
} }
private static void applyInitialCelestialPhase(ServerLevel level, AgeDefinition age) { private static void applyInitialCelestialPhase(ServerLevel level, AgeDefinition age) {
if (age.caveTerrain()) return;
long phaseTime = (long)(age.traits().celestialPhase() * Level.TICKS_PER_DAY); long phaseTime = (long)(age.traits().celestialPhase() * Level.TICKS_PER_DAY);
if (AgeEnvironmentCompiler.hasWrittenPhase(age.plan())) { if (AgeEnvironmentCompiler.hasWrittenPhase(age.plan())) {
// Legacy's Nadir is midnight, while vanilla day time zero is sunrise. // Legacy's Nadir is midnight, while vanilla day time zero is sunrise.
@@ -216,21 +230,24 @@ public final class AgeDimensionService {
return ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, definition.dimensionId()); return ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, definition.dimensionId());
} }
private static BlockPos createSafeSpawn(ServerLevel level, AgeDefinition age) { private static BlockPos createSafeSpawn(ServerLevel level, AgeDefinition age, boolean generatePlatform) {
BlockPos floorCenter; BlockPos floorCenter;
if (age.generationProfile().equals(AgeGenerationProfiles.CAVE)) { if (age.caveTerrain()) {
// Cave Ages are enclosed. Searching from the world ceiling would // Cave Ages are enclosed. Searching from the world ceiling would
// select or expose their roof, so begin with the floor beneath a // select or expose their roof, so begin with the floor beneath a
// player standing at Y=64 and search downward from there. // player standing at Y=64 and search downward from there.
floorCenter = highestFootedSurface(level, CAVE_SPAWN_SEARCH_Y - 1) floorCenter = highestFootedSurface(level, CAVE_SPAWN_SEARCH_Y - 1)
.orElseGet(() -> fallbackSpawnFloor(level)); .orElseGet(() -> generatePlatform ? fallbackSpawnFloor(level) : null);
} else if (isNetherStyle(level)) { } else if (isNetherStyle(level)) {
floorCenter = netherSpawnFloor(level); floorCenter = generatePlatform ? netherSpawnFloor(level)
: highestFootedSurface(level, CAVE_SPAWN_SEARCH_Y - 1).orElse(null);
} else { } else {
floorCenter = highestFootedSurface(level).orElseGet(() -> fallbackSpawnFloor(level)); floorCenter = highestFootedSurface(level).orElseGet(() -> generatePlatform ? fallbackSpawnFloor(level) : null);
} }
for (int x = -PLATFORM_RADIUS; x <= PLATFORM_RADIUS; x++) { if (floorCenter == null) return null;
if (generatePlatform) for (int x = -PLATFORM_RADIUS; x <= PLATFORM_RADIUS; x++) {
for (int z = -PLATFORM_RADIUS; z <= PLATFORM_RADIUS; z++) { for (int z = -PLATFORM_RADIUS; z <= PLATFORM_RADIUS; z++) {
BlockPos floor = floorCenter.offset(x, 0, z); BlockPos floor = floorCenter.offset(x, 0, z);
level.setBlock(floor, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 2); level.setBlock(floor, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 2);
@@ -241,6 +258,11 @@ public final class AgeDimensionService {
} }
BlockPos spawn = floorCenter.above(); BlockPos spawn = floorCenter.above();
if (!generatePlatform) {
var safe = LinkingService.resolveDestination(level, spawn, true);
if (safe.isEmpty()) return null;
spawn = BlockPos.containing(safe.get());
}
level.setDefaultSpawnPos(spawn, 0.0F); level.setDefaultSpawnPos(spawn, 0.0F);
return spawn; return spawn;
} }
@@ -10,13 +10,23 @@ public final class AgeEnvironmentCompiler {
public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) { public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) {
if (plan == null) return seeded; if (plan == null) return seeded;
AgeTraits applied;
if (plan.version() >= 4) { if (plan.version() >= 4) {
AgeTraits base = applyLegacy(plan, seeded); AgeTraits base = applyLegacy(plan, seeded);
CelestialWriting ordered = orderedCelestial(plan, seeded); CelestialWriting ordered = orderedCelestial(plan, seeded);
return new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(), applied = new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(),
ordered.period(), base.celestialAngle(), ordered.phase()); ordered.period(), base.celestialAngle(), ordered.phase());
} else {
applied = applyLegacy(plan, seeded);
} }
return applyLegacy(plan, seeded); return forTerrain(applied, plan.runtimeProfile());
}
/** Cave terrain has no exposed weather or meaningful sky clock, even with authored pages. */
public static AgeTraits forTerrain(AgeTraits traits, AgeRuntimeProfile profile) {
if (!AgeBiomeSelection.isCave(profile)) return traits;
return new AgeTraits(traits.biomeDistribution(), traits.lighting(), AgeTraits.Weather.OFF,
1.0D, 0.0F, 0.0F);
} }
/** Version-3 plans keep their frozen, stage-sorted environment interpretation. */ /** Version-3 plans keep their frozen, stage-sorted environment interpretation. */
@@ -15,6 +15,7 @@ import net.minecraft.resources.ResourceLocation;
public record AgeInstability(int version, int score, List<Contribution> breakdown) { public record AgeInstability(int version, int score, List<Contribution> breakdown) {
public static final int CURRENT_VERSION = 2; public static final int CURRENT_VERSION = 2;
private static final int BRIGHT_LIGHTING_COST = 500; private static final int BRIGHT_LIGHTING_COST = 500;
private static final int INEFFECTIVE_CAVE_PAGE_COST = 100;
private static final int EXCESS_LEGACY_STRUCTURE_COST = 100; private static final int EXCESS_LEGACY_STRUCTURE_COST = 100;
private static final int FORCED_STRUCTURE_BIOME_COST = 100; private static final int FORCED_STRUCTURE_BIOME_COST = 100;
private static final Map<ResourceLocation, String> COUNTED_STRUCTURES = Map.ofEntries( private static final Map<ResourceLocation, String> COUNTED_STRUCTURES = Map.ofEntries(
@@ -88,7 +89,19 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
private static void addAuditedSymbolCosts(AgeGenerationPlan plan, RegistryAccess registries, private static void addAuditedSymbolCosts(AgeGenerationPlan plan, RegistryAccess registries,
List<Contribution> contributions) { List<Contribution> contributions) {
Map<ResourceLocation, Integer> structureCounts = new HashMap<>(); Map<ResourceLocation, Integer> structureCounts = new HashMap<>();
boolean cave = AgeBiomeSelection.isCave(plan.runtimeProfile());
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) { for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
if (cave) {
var role = symbol.semantics().role();
if (switch (role) {
case WEATHER, SEASON, CELESTIAL, PERIOD_MODIFIER, PHASE_MODIFIER, ANGLE_MODIFIER -> true;
default -> false;
}) {
contributions.add(new Contribution("cave_ignored_" + role.id(), symbol.index(),
INEFFECTIVE_CAVE_PAGE_COST,
"Sky or weather page has no effect in Cave terrain"));
}
}
if (symbol.semantics().role() if (symbol.semantics().role()
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.ENVIRONMENT == net.dnorberg.rivencraft.writing.SymbolSemantics.Role.ENVIRONMENT
&& symbol.semantics().value() && symbol.semantics().value()
@@ -23,6 +23,49 @@ public final class AgePersistenceGameTests {
private AgePersistenceGameTests() { private AgePersistenceGameTests() {
} }
@GameTest(template = "empty")
public static void modifierSeedChangePersistsWithoutChangingAgeIdentity(GameTestHelper helper) {
UUID id = UUID.randomUUID();
AgeDefinition original = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(id, "Reseed " + id));
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
long newSeed = original.seed() == 987654321L ? 987654322L : 987654321L;
helper.assertTrue(registry.reseed(helper.getLevel().getServer(), id, newSeed),
"An unoccupied Age must accept a new seed");
AgeDefinition changed = registry.find(id).orElseThrow();
helper.assertValueEqual(changed.id(), original.id(), "Reseeding must preserve the Age identity");
helper.assertValueEqual(changed.dimensionId(), original.dimensionId(),
"Reseeding must preserve its dimension and existing chunk location");
helper.assertValueEqual(changed.seed(), newSeed, "The new generator seed must be saved");
AgeDefinition restored = AgeRegistrySavedData.load(
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).find(id).orElseThrow();
helper.assertValueEqual(restored.seed(), newSeed, "The new seed must survive reload");
var ageLevel = AgeDimensionService.ensureLoaded(helper.getLevel().getServer(), changed);
helper.assertTrue(ageLevel != null, "The reseeded Age must reload");
helper.assertValueEqual(ageLevel.getSeed(), newSeed,
"The runtime level must use the saved seed for new chunks");
helper.succeed();
}
@GameTest(template = "empty")
public static void retiredAgeIdentitySurvivesSaveAndCannotLoad(GameTestHelper helper) {
UUID id = UUID.randomUUID();
AgeReference reference = new AgeReference(id, "Retired " + id);
AgeDefinition age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(), reference);
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
helper.assertTrue(registry.markDead(id), "The first retirement must set the tombstone");
helper.assertTrue(!registry.markDead(id), "Retirement must be idempotent");
helper.assertTrue(AgeDimensionService.ensureLoaded(helper.getLevel().getServer(), age) == null,
"A retired Age must never load from an old book");
AgeRegistrySavedData restored = AgeRegistrySavedData.load(
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess());
helper.assertTrue(restored.isDead(id) && restored.find(id).isPresent(),
"Both the retired flag and reserved Age identity must survive reload");
helper.succeed();
}
@GameTest(template = "empty") @GameTest(template = "empty")
public static void authoredPlanRetainsResolvedMeaningAndAgeIdentity(GameTestHelper helper) { public static void authoredPlanRetainsResolvedMeaningAndAgeIdentity(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of( OrderedWriting writing = new OrderedWriting(List.of(
@@ -5,6 +5,9 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.List; import java.util.List;
import java.util.HashSet;
import java.util.Set;
import net.minecraft.nbt.StringTag;
import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup; import net.minecraft.core.HolderLookup;
@@ -13,6 +16,7 @@ import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag; import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.datafix.DataFixTypes; import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData; import net.minecraft.world.level.saveddata.SavedData;
import net.neoforged.fml.ModList; import net.neoforged.fml.ModList;
@@ -21,6 +25,7 @@ import net.neoforged.fml.ModList;
public final class AgeRegistrySavedData extends SavedData { public final class AgeRegistrySavedData extends SavedData {
private static final String DATA_NAME = "rivencraft_ages"; private static final String DATA_NAME = "rivencraft_ages";
private static final String AGES_TAG = "Ages"; private static final String AGES_TAG = "Ages";
private static final String DEAD_AGES_TAG = "DeadAges";
private static final String ID_TAG = "Id"; private static final String ID_TAG = "Id";
private static final String NAME_TAG = "Name"; private static final String NAME_TAG = "Name";
private static final String SEED_TAG = "Seed"; private static final String SEED_TAG = "Seed";
@@ -50,6 +55,7 @@ public final class AgeRegistrySavedData extends SavedData {
); );
private final Map<UUID, AgeDefinition> ages = new HashMap<>(); private final Map<UUID, AgeDefinition> ages = new HashMap<>();
private final Set<UUID> deadAges = new HashSet<>();
public static AgeRegistrySavedData get(ServerLevel level) { public static AgeRegistrySavedData get(ServerLevel level) {
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME); return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
@@ -110,6 +116,16 @@ public final class AgeRegistrySavedData extends SavedData {
return List.copyOf(ages.values()); return List.copyOf(ages.values());
} }
public boolean isDead(UUID ageId) { return deadAges.contains(ageId); }
/** Keep the ID and definition reserved so an old book cannot regenerate a retired Age. */
public boolean markDead(UUID ageId) {
if (!ages.containsKey(ageId)) return false;
boolean changed = deadAges.add(ageId);
if (changed) setDirty();
return changed;
}
public AgeDefinition setSpawn(UUID ageId, BlockPos spawn) { public AgeDefinition setSpawn(UUID ageId, BlockPos spawn) {
AgeDefinition existing = ages.get(ageId); AgeDefinition existing = ages.get(ageId);
if (existing == null) { if (existing == null) {
@@ -121,6 +137,32 @@ public final class AgeRegistrySavedData extends SavedData {
return updated; return updated;
} }
/** Changes the generator for future chunks; existing chunk data is deliberately preserved. */
public boolean reseed(MinecraftServer server, UUID ageId, long seed) {
AgeDefinition previous = ages.get(ageId);
if (previous == null || isDead(ageId)) return false;
if (previous.seed() == seed) return true;
ServerLevel loaded = server.getLevel(AgeDimensionService.dimensionKey(previous));
if (loaded != null && !loaded.players().isEmpty()) return false;
// Legacy changes the Age's seed, not its already-written pages or selected traits.
// Keeping the frozen plan and feature list prevents a visited Age from changing
// its identity while newly generated chunks use the revised terrain noise.
AgeGenerationPlan oldPlan = previous.plan();
AgeGenerationPlan plan = oldPlan == null ? null : new AgeGenerationPlan(oldPlan.version(), seed,
oldPlan.writing(), oldPlan.symbols(), oldPlan.runtimeProfile(), oldPlan.biomeSelection(),
oldPlan.activeSymbolIndices(), oldPlan.writingInstability());
AgeDefinition changed = new AgeDefinition(previous.id(), previous.name(), seed,
previous.generationProfile(), previous.generationVersion(), previous.createdGameTime(),
previous.dimensionId(), previous.spawn(), previous.traits(), plan, previous.seasonMode(),
previous.randomFeatures(), previous.instability(), previous.instabilityPlan());
if (AgeDimensionService.unload(server, previous) == AgeDimensionService.UnloadResult.FAILED)
return false;
ages.put(ageId, changed);
setDirty();
return true;
}
private AgeDefinition getOrCreate(AgeReference reference, OrderedWriting writing, private AgeDefinition getOrCreate(AgeReference reference, OrderedWriting writing,
long worldSeed, long createdGameTime, RegistryAccess registries) { long worldSeed, long createdGameTime, RegistryAccess registries) {
AgeDefinition existing = ages.get(reference.id()); AgeDefinition existing = ages.get(reference.id());
@@ -134,7 +176,9 @@ public final class AgeRegistrySavedData extends SavedData {
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries); ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
AgeTraits traits = AgeTraits.select(seed); AgeTraits traits = AgeTraits.select(seed);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries); AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
AgeTraits effectiveTraits = AgeEnvironmentCompiler.apply(plan, traits); AgeTraits effectiveTraits = plan == null
? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile))
: AgeEnvironmentCompiler.apply(plan, traits);
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries); AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries);
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed); AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
AgeDefinition created = new AgeDefinition( AgeDefinition created = new AgeDefinition(
@@ -148,7 +192,8 @@ public final class AgeRegistrySavedData extends SavedData {
null, null,
effectiveTraits, effectiveTraits,
plan, plan,
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")), AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"),
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile()),
plan == null ? AgeFeatureSelection.randomChoices(seed, plan == null ? AgeFeatureSelection.randomChoices(seed,
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(), AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
instability, instability,
@@ -161,6 +206,11 @@ public final class AgeRegistrySavedData extends SavedData {
static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) { static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeRegistrySavedData data = new AgeRegistrySavedData(); AgeRegistrySavedData data = new AgeRegistrySavedData();
ListTag deadTags = tag.getList(DEAD_AGES_TAG, StringTag.TAG_STRING);
for (int index = 0; index < deadTags.size(); index++) {
try { data.deadAges.add(UUID.fromString(deadTags.getString(index))); }
catch (IllegalArgumentException ignored) { }
}
ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND); ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND);
for (int index = 0; index < serializedAges.size(); index++) { for (int index = 0; index < serializedAges.size(); index++) {
CompoundTag entry = serializedAges.getCompound(index); CompoundTag entry = serializedAges.getCompound(index);
@@ -264,6 +314,9 @@ public final class AgeRegistrySavedData extends SavedData {
serializedAges.add(entry); serializedAges.add(entry);
}); });
tag.put(AGES_TAG, serializedAges); tag.put(AGES_TAG, serializedAges);
ListTag deadTags = new ListTag();
deadAges.stream().sorted().forEach(id -> deadTags.add(StringTag.valueOf(id.toString())));
tag.put(DEAD_AGES_TAG, deadTags);
return tag; return tag;
} }
@@ -19,6 +19,12 @@ public enum AgeSeasonMode {
/** An omitted page is selected once when an Age is created with Serene installed. */ /** An omitted page is selected once when an Age is created with Serene installed. */
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled) { public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled) {
return select(plan, ageSeed, sereneInstalled, plan == null ? null : plan.runtimeProfile());
}
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
AgeRuntimeProfile profile) {
if (profile != null && AgeBiomeSelection.isCave(profile)) return NO_SEASONS;
if (hasAuthoredPage(plan)) return from(plan); if (hasAuthoredPage(plan)) return from(plan);
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of( return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
new LegacyRankChoice.Option<>(SEASONS, 1), new LegacyRankChoice.Option<>(SEASONS, 1),
@@ -31,6 +37,7 @@ public enum AgeSeasonMode {
/** Existing Ages without a stored choice retain their old cycling behavior. */ /** Existing Ages without a stored choice retain their old cycling behavior. */
public static AgeSeasonMode from(AgeDefinition age) { public static AgeSeasonMode from(AgeDefinition age) {
if (age.caveTerrain()) return NO_SEASONS;
return age.seasonMode() == null ? from(age.plan()) : age.seasonMode(); return age.seasonMode() == null ? from(age.plan()) : age.seasonMode();
} }
@@ -49,6 +56,7 @@ public enum AgeSeasonMode {
public static AgeSeasonMode from(AgeGenerationPlan plan) { public static AgeSeasonMode from(AgeGenerationPlan plan) {
if (plan == null) return SEASONS; if (plan == null) return SEASONS;
if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return NO_SEASONS;
return plan.activeSymbols().stream() return plan.activeSymbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON) .filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON)
.map(symbol -> valueOf(((SymbolSemantics.ChoiceValue) symbol.semantics().value()) .map(symbol -> valueOf(((SymbolSemantics.ChoiceValue) symbol.semantics().value())
@@ -58,6 +66,7 @@ public enum AgeSeasonMode {
public static void validate(AgeGenerationPlan plan) { public static void validate(AgeGenerationPlan plan) {
if (plan == null) return; if (plan == null) return;
if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return;
if (hasAuthoredPage(plan) && from(plan) != NO_SEASONS if (hasAuthoredPage(plan) && from(plan) != NO_SEASONS
&& !ModList.get().isLoaded("sereneseasons")) { && !ModList.get().isLoaded("sereneseasons")) {
throw new IllegalArgumentException("This seasonal page requires Serene Seasons to create the Age"); throw new IllegalArgumentException("This seasonal page requires Serene Seasons to create the Age");
@@ -26,7 +26,7 @@ public final class AgeWeatherService {
return; return;
} }
AgeTraits.Weather weather = age.traits().weather(); AgeTraits.Weather weather = age.caveTerrain() ? AgeTraits.Weather.OFF : age.traits().weather();
if (weather == AgeTraits.Weather.NORMAL) { if (weather == AgeTraits.Weather.NORMAL) {
STATES.remove(level); STATES.remove(level);
return; return;
@@ -0,0 +1,90 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.biome.Biomes;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Focused Cave terrain writing and persisted-generation coverage for issue #14. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class CaveTerrainGameTests {
private CaveTerrainGameTests() {}
@GameTest(template = "empty")
public static void caveSkyAndWeatherPagesCostInstabilityButDoNotApply(GameTestHelper helper) {
OrderedWriting cave = writing("terrain/cave", "biome/lush_caves", "biome/nether_wastes",
"biome/tiled", "weather/storm", "season/eternal_winter", "period/zero",
"celestial/normal_time");
AgeTraits seeded = new AgeTraits(AgeTraits.BiomeDistribution.SINGLE, AgeTraits.Lighting.NORMAL,
AgeTraits.Weather.ALWAYS, 0.5D, 0.0F, 0.25F);
var registries = helper.getLevel().registryAccess();
AgeGenerationPlan plan = AgeGenerationPlan.compile(cave, 42L, AgeGenerationProfiles.CAVE,
seeded, registries);
AgeTraits applied = AgeEnvironmentCompiler.apply(plan, seeded);
helper.assertValueEqual(applied.weather(), AgeTraits.Weather.OFF, "Cave weather must be off");
helper.assertValueEqual(applied.celestialPeriod(), 1.0D, "Cave time pages must not alter the clock");
helper.assertValueEqual(applied.celestialPhase(), 0.0F, "Cave time pages must not set a phase");
helper.assertValueEqual(AgeSeasonMode.select(plan, 42L, true), AgeSeasonMode.NO_SEASONS,
"Cave seasons must be disabled even when Serene Seasons is present");
helper.assertValueEqual(plan.biomeSelection().biomes(),
List.of(Biomes.LUSH_CAVES.location(), Biomes.NETHER_WASTES.location()),
"Cave writing must retain exactly its cave and Nether biomes");
AgeInstability instability = AgeInstability.compute(plan, applied, registries);
helper.assertValueEqual(instability.breakdown().stream()
.filter(part -> part.reason().startsWith("cave_ignored_")).count(), 4L,
"Each ineffective weather, season, period, and celestial page must add instability");
helper.assertValueEqual(AgeInstability.compute(AgeGenerationPlan.load(plan.save()), applied, registries),
instability, "Cave page penalties must survive plan reload");
AgeGenerationPlan surfaceCaves = AgeGenerationPlan.compile(writing("terrain/standard", "feature/caves"),
42L, AgeGenerationProfiles.STANDARD, seeded, registries);
helper.assertTrue(!AgeBiomeSelection.isCave(surfaceCaves.runtimeProfile())
&& AgeFeatureSelection.resolved(surfaceCaves).stream()
.anyMatch(choice -> AgeFeatureSelection.caves(choice.id())),
"The separate Caves feature page must still add caves to surface terrain");
helper.succeed();
}
@GameTest(template = "empty", timeoutTicks = 600)
public static void authoredCaveBiomeAndSpawnSurviveSavedDataReload(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
AgeReference reference = new AgeReference(UUID.randomUUID(), "Cave issue 14 " + UUID.randomUUID());
AgeDimensionService.resolve(player, reference,
writing("terrain/cave", "biome/nether_wastes", "biome/single")).orElseThrow();
var registry = AgeRegistrySavedData.get(helper.getLevel());
AgeDefinition age = registry.find(reference.id()).orElseThrow();
helper.assertTrue(age.caveTerrain(), "The Age must retain Cave terrain");
helper.assertTrue(age.spawn() != null && age.spawn().getY() <= 64,
"Cave spawn search must start at Y=64, not atop the roof");
helper.assertValueEqual(age.traits().weather(), AgeTraits.Weather.OFF,
"A new Cave Age must persist disabled weather");
helper.assertValueEqual(age.seasonMode(), AgeSeasonMode.NO_SEASONS,
"A new Cave Age must persist disabled seasons");
AgeDefinition reloaded = AgeRegistrySavedData.load(
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).find(reference.id()).orElseThrow();
helper.assertValueEqual(reloaded, age, "Saved Cave biome, spawn, and environment must survive reload");
helper.assertValueEqual(reloaded.plan().biomeSelection().biomes(),
List.of(Biomes.NETHER_WASTES.location()),
"The generated Cave Age must preserve its authored-only Nether pool");
helper.succeed();
}
private static OrderedWriting writing(String... pages) {
var entries = new java.util.ArrayList<WritingEntry>();
entries.add(WritingEntry.linkPanel());
for (String page : pages) entries.add(WritingEntry.symbol(new SymbolReference(
ResourceLocation.parse("rivencraft:" + page))));
return new OrderedWriting(entries);
}
}
@@ -20,6 +20,9 @@ public final class CloudPlatformGameTests {
helper.getLevel().setBlock(pos, state, 2); helper.getLevel().setBlock(pos, state, 2);
helper.assertTrue(!state.getCollisionShape(helper.getLevel(), pos).isEmpty(), helper.assertTrue(!state.getCollisionShape(helper.getLevel(), pos).isEmpty(),
"Cloud platform must support a standing player"); "Cloud platform must support a standing player");
helper.assertTrue(state.canOcclude(), "Arrival platform must be opaque");
helper.assertValueEqual(state.getDestroySpeed(helper.getLevel(), pos), 0.3F,
"Arrival platform should break as easily as glass");
helper.assertTrue(ModBlocks.CLOUD_PLATFORM.get().getCloneItemStack(helper.getLevel(), pos, state).isEmpty(), helper.assertTrue(ModBlocks.CLOUD_PLATFORM.get().getCloneItemStack(helper.getLevel(), pos, state).isEmpty(),
"Cloud platform must not provide a pick-block item"); "Cloud platform must not provide a pick-block item");
helper.succeed(); helper.succeed();
@@ -0,0 +1,127 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.dnorberg.rivencraft.registry.ModEntities;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class HandheldBookTravelGameTests {
private HandheldBookTravelGameTests() {
}
@GameTest(template = "empty")
public static void successfulHandheldTravelLeavesOnlyActiveBookAtOrigin(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
player.setItemInHand(InteractionHand.MAIN_HAND, book);
player.getInventory().setItem(1, new ItemStack(Items.DIAMOND));
var source = player.position();
BlockPos destination = helper.absolutePos(new BlockPos(2, 1, 2));
helper.getLevel().setBlock(destination, Blocks.AIR.defaultBlockState(), 2);
helper.getLevel().setBlock(destination.above(), Blocks.AIR.defaultBlockState(), 2);
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), destination), 0, 0);
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.MAIN_HAND, book, target,
new LinkProperties(LinkProperties.INTRA_LINKING), false), LinkingService.LinkResult.SUCCESS,
"A valid intra-dimensional handheld link must succeed");
helper.assertTrue(player.getMainHandItem().isEmpty(), "Only the active book must leave the hand");
helper.assertTrue(player.getInventory().getItem(1).is(Items.DIAMOND),
"An unrelated inventory item must remain with the player");
var drops = helper.getLevel().getEntitiesOfClass(DroppedBookEntity.class, new AABB(source, source).inflate(2.0),
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get()));
helper.assertValueEqual(drops.size(), 1, "Exactly one recoverable book must remain at the departure point");
helper.assertTrue(drops.getFirst().getItem() == book,
"The model-rendered entity must retain the exact active book stack");
helper.assertTrue(drops.getFirst().copy() instanceof DroppedBookEntity,
"The pickup-animation copy must keep the custom renderer's entity type");
helper.succeed();
}
@GameTest(template = "empty")
public static void rejectedHandheldTravelKeepsBook(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
player.setItemInHand(InteractionHand.OFF_HAND, book);
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), player.blockPosition()), 0, 0);
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.OFF_HAND, book, target,
LinkProperties.NONE, false), LinkingService.LinkResult.INTRA_LINKING_REQUIRED,
"A same-dimension link without Intra Linking must be rejected");
helper.assertTrue(player.getOffhandItem() == book, "The exact rejected book must be restored to its hand");
helper.assertTrue(helper.getLevel().getEntitiesOfClass(ItemEntity.class,
new AABB(player.position(), player.position()).inflate(2.0),
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get())).isEmpty(),
"A rejected link must not leave a second copy at the origin");
helper.succeed();
}
@GameTest(template = "empty")
public static void disarmDropsOtherInventoryWithoutDuplicatingActiveBook(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
player.setItemInHand(InteractionHand.MAIN_HAND, book);
player.getInventory().setItem(1, new ItemStack(Items.DIAMOND));
var source = player.position();
BlockPos destination = helper.absolutePos(new BlockPos(2, 1, 2));
helper.getLevel().setBlock(destination, Blocks.AIR.defaultBlockState(), 2);
helper.getLevel().setBlock(destination.above(), Blocks.AIR.defaultBlockState(), 2);
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), destination), 0, 0);
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.MAIN_HAND, book, target,
new LinkProperties(LinkProperties.INTRA_LINKING | LinkProperties.DISARM), false),
LinkingService.LinkResult.SUCCESS, "Disarm must not block handheld travel");
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(source, source).inflate(2.0));
helper.assertValueEqual(drops.stream().filter(entity -> entity instanceof DroppedBookEntity).count(),
1L, "Disarm must not create a second copy of the active book");
helper.assertValueEqual(drops.stream().filter(entity -> entity.getItem().is(Items.DIAMOND)).count(),
1L, "Disarm must still drop unrelated inventory items");
helper.succeed();
}
@GameTest(template = "empty")
public static void originBookUsesSlowDecayWithoutVanillaDespawn(GameTestHelper helper) {
DroppedBookEntity book = new DroppedBookEntity(helper.getLevel(),
net.minecraft.world.phys.Vec3.ZERO, new ItemStack(ModItems.LINKING_BOOK.get()));
CompoundTag seeded = new CompoundTag();
book.saveWithoutId(seeded);
seeded.putInt("BookDecayTicks", 9_999);
book.readAdditionalSaveData(seeded);
book.tick();
CompoundTag saved = new CompoundTag();
book.saveWithoutId(saved);
helper.assertValueEqual(saved.getShort("Age"), (short)-32_768,
"The dropped book must not expire after vanilla's five minutes");
helper.assertValueEqual(saved.getShort("Health"), (short)4,
"A dropped book must lose one item-health point after 10,000 loaded ticks");
helper.assertValueEqual(saved.getInt("BookDecayTicks"), 0,
"Slow decay must reset its interval and persist across a restart");
DroppedBookEntity restored = new DroppedBookEntity(ModEntities.DROPPED_BOOK.get(), helper.getLevel());
restored.readAdditionalSaveData(saved);
CompoundTag resaved = new CompoundTag();
restored.saveWithoutId(resaved);
helper.assertTrue(restored.getItem().is(ModItems.LINKING_BOOK.get()),
"The custom book entity must retain its book on reload");
helper.assertValueEqual(resaved.getShort("Age"), (short)-32_768,
"The custom book must remain non-despawning after reload");
helper.assertValueEqual(resaved.getShort("Health"), (short)4,
"Decay damage must persist after reload");
helper.assertTrue(restored.copy().getItem().is(ModItems.LINKING_BOOK.get()),
"A pickup-animation copy must retain the book data after reload");
helper.succeed();
}
}
@@ -11,11 +11,15 @@ import net.minecraft.network.chat.Component;
/** Flags written into a Link Panel and carried by books assembled from it. */ /** Flags written into a Link Panel and carried by books assembled from it. */
public record LinkProperties(int flags) { public record LinkProperties(int flags) {
public static final int DISARM = 1; public static final int DISARM = 1;
/** Retained only to read pre-0.4.7.1 test books; no longer generated or applied. */ /** Retained to read older test books; a newly edited book uses DISABLE_PLATFORM instead. */
public static final int GENERATE_PLATFORM = 2; public static final int GENERATE_PLATFORM = 2;
public static final int MAINTAIN_MOMENTUM = 4; public static final int MAINTAIN_MOMENTUM = 4;
public static final int INTRA_LINKING = 8; public static final int INTRA_LINKING = 8;
private static final int ALL_FLAGS = DISARM | GENERATE_PLATFORM | MAINTAIN_MOMENTUM | INTRA_LINKING; public static final int INTRA_LINKING_ONLY = 16;
public static final int RELATIVE_LINK = 32;
public static final int DISABLE_PLATFORM = 64;
private static final int ALL_FLAGS = DISARM | GENERATE_PLATFORM | MAINTAIN_MOMENTUM | INTRA_LINKING
| INTRA_LINKING_ONLY | RELATIVE_LINK | DISABLE_PLATFORM;
public static final LinkProperties NONE = new LinkProperties(0); public static final LinkProperties NONE = new LinkProperties(0);
public static final Codec<LinkProperties> CODEC = Codec.intRange(0, ALL_FLAGS).xmap( public static final Codec<LinkProperties> CODEC = Codec.intRange(0, ALL_FLAGS).xmap(
LinkProperties::new, LinkProperties::flags); LinkProperties::new, LinkProperties::flags);
@@ -35,13 +39,16 @@ public record LinkProperties(int flags) {
/** Intra Linking only governs Linking Books; a Descriptive Book treats that panel as ordinary. */ /** Intra Linking only governs Linking Books; a Descriptive Book treats that panel as ordinary. */
public LinkProperties forDescriptiveBook() { public LinkProperties forDescriptiveBook() {
return new LinkProperties(flags & ~INTRA_LINKING); return new LinkProperties(flags & ~(INTRA_LINKING | INTRA_LINKING_ONLY));
} }
public void appendTooltip(List<Component> lines) { public void appendTooltip(List<Component> lines) {
if (has(DISARM)) lines.add(propertyName("disarm")); if (has(DISARM)) lines.add(propertyName("disarm"));
if (has(MAINTAIN_MOMENTUM)) lines.add(propertyName("maintain_momentum")); if (has(MAINTAIN_MOMENTUM)) lines.add(propertyName("maintain_momentum"));
if (has(INTRA_LINKING)) lines.add(propertyName("intra_linking")); if (has(INTRA_LINKING)) lines.add(propertyName("intra_linking"));
if (has(INTRA_LINKING_ONLY)) lines.add(propertyName("intra_linking_only"));
if (has(RELATIVE_LINK)) lines.add(propertyName("relative_link"));
if (has(DISABLE_PLATFORM)) lines.add(propertyName("disable_platform"));
} }
private static Component propertyName(String name) { private static Component propertyName(String name) {
@@ -54,6 +61,9 @@ public record LinkProperties(int flags) {
case DISARM -> propertyName("disarm"); case DISARM -> propertyName("disarm");
case MAINTAIN_MOMENTUM -> propertyName("maintain_momentum"); case MAINTAIN_MOMENTUM -> propertyName("maintain_momentum");
case INTRA_LINKING -> propertyName("intra_linking"); case INTRA_LINKING -> propertyName("intra_linking");
case INTRA_LINKING_ONLY -> propertyName("intra_linking_only");
case RELATIVE_LINK -> propertyName("relative_link");
case DISABLE_PLATFORM -> propertyName("disable_platform");
default -> throw new IllegalArgumentException("Unknown link property flag: " + flag); default -> throw new IllegalArgumentException("Unknown link property flag: " + flag);
}; };
} }
@@ -0,0 +1,41 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.registry.ModSounds;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.phys.Vec3;
/** Legacy-style sound and particle bursts at both ends of a successful link. */
public final class LinkVisualEffects {
private static final int PARTICLE_COUNT = 50;
private LinkVisualEffects() {}
public static SoundEvent sound(LinkProperties properties, boolean portal) {
// Legacy's Disarm flag takes precedence over a portal's sound override.
if (properties.has(LinkProperties.DISARM)) return ModSounds.LINK_DISARM.get();
if (portal) return ModSounds.LINK_PORTAL.get();
if (properties.has(LinkProperties.INTRA_LINKING)) return ModSounds.LINK_INTRA.get();
return ModSounds.LINK.get();
}
public static void play(ServerLevel source, Vec3 departure, ServerPlayer player,
LinkProperties properties, boolean portal) {
SoundEvent event = sound(properties, portal);
burst(source, departure, event);
burst(player.serverLevel(), player.position(), event);
}
private static void burst(ServerLevel level, Vec3 position, SoundEvent event) {
level.playSound(null, position.x, position.y, position.z, event,
SoundSource.PLAYERS, 0.8F, level.random.nextFloat() * 0.2F + 0.9F);
// Legacy emitted 50 small neutral particles in a roughly one-by-two-
// block space. Cloud particles are a vanilla gray-white stand-in for
// its removed fixed-function particle class.
level.sendParticles(ParticleTypes.CLOUD, position.x, position.y, position.z,
PARTICLE_COUNT, 0.5, 1.0, 0.5, 0.005);
}
}
@@ -5,10 +5,13 @@ import java.util.Optional;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import java.util.stream.Stream; import java.util.stream.Stream;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders; import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction; import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.tags.BlockTags; import net.minecraft.tags.BlockTags;
import net.minecraft.world.level.border.WorldBorder; import net.minecraft.world.level.border.WorldBorder;
import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.Heightmap;
@@ -29,7 +32,42 @@ public final class LinkingService {
} }
public static LinkResult travel(ServerPlayer player, LinkTarget target, LinkProperties properties) { public static LinkResult travel(ServerPlayer player, LinkTarget target, LinkProperties properties) {
return travel(player, target, false, properties); return travel(player, target, false, properties, false);
}
/** Removes only the active handheld book after a successful link, leaving it at the departure point. */
public static LinkResult travelHandheld(ServerPlayer player, InteractionHand hand, ItemStack activeBook,
LinkTarget target, LinkProperties properties, boolean requiresFooting) {
if (player.getItemInHand(hand) != activeBook || activeBook.isEmpty()) {
return LinkResult.NO_SAFE_DESTINATION;
}
ServerLevel sourceLevel = player.serverLevel();
Vec3 sourcePosition = player.position();
player.setItemInHand(hand, ItemStack.EMPTY);
boolean dropped = false;
try {
LinkResult result = requiresFooting
? travelWithFooting(player, target, properties)
: travel(player, target, properties);
if (result != LinkResult.SUCCESS) return result;
DroppedBookEntity book = new DroppedBookEntity(sourceLevel, sourcePosition, activeBook);
dropped = sourceLevel.addFreshEntity(book);
return result;
} finally {
// Failed validation or a rejected entity spawn must never eat the book.
if (!dropped) {
if (player.getItemInHand(hand).isEmpty()) player.setItemInHand(hand, activeBook);
else if (!player.getInventory().add(activeBook)) player.drop(activeBook, false);
}
player.containerMenu.broadcastChanges();
}
}
/** Portal travel uses the vanilla transfer if its destination is not yet loaded. */
public static LinkResult travelPortal(ServerPlayer player, LinkTarget target, LinkProperties properties) {
return travel(player, target, false, properties, true);
} }
public static Optional<Vec3> resolveDestination(ServerLevel level, BlockPos origin, boolean ageArrival) { public static Optional<Vec3> resolveDestination(ServerLevel level, BlockPos origin, boolean ageArrival) {
@@ -48,7 +86,10 @@ public final class LinkingService {
if (destinationLevel == null) { if (destinationLevel == null) {
return LinkResult.MISSING_DIMENSION; return LinkResult.MISSING_DIMENSION;
} }
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), true); if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != player.serverLevel())
return LinkResult.INTRA_ONLY;
BlockPos origin = landingOrigin(player, destinationLevel, target, properties);
Optional<Vec3> destination = resolveDestination(destinationLevel, origin, true);
if (destination.isEmpty()) { if (destination.isEmpty()) {
return LinkResult.NO_SAFE_DESTINATION; return LinkResult.NO_SAFE_DESTINATION;
} }
@@ -56,36 +97,45 @@ public final class LinkingService {
Vec3 position = destination.get(); Vec3 position = destination.get();
Vec3 momentum = player.getDeltaMovement(); Vec3 momentum = player.getDeltaMovement();
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player); if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
teleport(player, destinationLevel, position, target); teleport(player, destinationLevel, position, target, true);
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum); if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
player.resetFallDistance(); player.resetFallDistance();
return LinkResult.SUCCESS; return LinkResult.SUCCESS;
} }
/** A descriptive portal uses the book's Age destination without requiring a landing platform. */ /** A descriptive portal requires existing footing, but never generates a landing platform. */
public static LinkResult travelDescriptivePortal(ServerPlayer player, LinkTarget target, LinkProperties properties) { public static LinkResult travelDescriptivePortal(ServerPlayer player, LinkTarget target, LinkProperties properties) {
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension()); ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
if (destinationLevel == null) return LinkResult.MISSING_DIMENSION; if (destinationLevel == null) return LinkResult.MISSING_DIMENSION;
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), false); boolean instant = destinationChunksLoaded(destinationLevel, target.location().pos());
if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != player.serverLevel())
return LinkResult.INTRA_ONLY;
Optional<Vec3> destination = resolveDestination(destinationLevel,
landingOrigin(player, destinationLevel, target, properties), true);
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION; if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
Vec3 momentum = player.getDeltaMovement(); Vec3 momentum = player.getDeltaMovement();
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player); if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
teleport(player, destinationLevel, destination.get(), target); teleport(player, destinationLevel, destination.get(), target, instant);
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum); if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
player.resetFallDistance(); player.resetFallDistance();
return LinkResult.SUCCESS; return LinkResult.SUCCESS;
} }
private static LinkResult travel(ServerPlayer player, LinkTarget target, boolean requiresFooting, private static LinkResult travel(ServerPlayer player, LinkTarget target, boolean requiresFooting,
LinkProperties properties) { LinkProperties properties, boolean portal) {
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension()); ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
if (destinationLevel == null) { if (destinationLevel == null) {
return LinkResult.MISSING_DIMENSION; return LinkResult.MISSING_DIMENSION;
} }
if (destinationLevel == player.level() && !properties.has(LinkProperties.INTRA_LINKING)) if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != player.serverLevel())
return LinkResult.INTRA_ONLY;
if (destinationLevel == player.level() && !properties.has(LinkProperties.INTRA_LINKING)
&& !properties.has(LinkProperties.INTRA_LINKING_ONLY))
return LinkResult.INTRA_LINKING_REQUIRED; return LinkResult.INTRA_LINKING_REQUIRED;
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), requiresFooting); boolean instant = !portal || destinationChunksLoaded(destinationLevel, target.location().pos());
Optional<Vec3> destination = resolveDestination(destinationLevel,
landingOrigin(player, destinationLevel, target, properties), requiresFooting);
if (destination.isEmpty()) { if (destination.isEmpty()) {
return LinkResult.NO_SAFE_DESTINATION; return LinkResult.NO_SAFE_DESTINATION;
} }
@@ -93,12 +143,23 @@ public final class LinkingService {
Vec3 position = destination.get(); Vec3 position = destination.get();
Vec3 momentum = player.getDeltaMovement(); Vec3 momentum = player.getDeltaMovement();
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player); if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
teleport(player, destinationLevel, position, target); teleport(player, destinationLevel, position, target, instant);
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum); if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
player.resetFallDistance(); player.resetFallDistance();
return LinkResult.SUCCESS; return LinkResult.SUCCESS;
} }
/** Legacy relative links retain the traveller's offset from the source spawn. */
private static BlockPos landingOrigin(ServerPlayer player, ServerLevel destinationLevel,
LinkTarget target, LinkProperties properties) {
if (!properties.has(LinkProperties.RELATIVE_LINK)) return target.location().pos();
BlockPos sourceSpawn = player.serverLevel().getSharedSpawnPos();
BlockPos destinationSpawn = destinationLevel.getSharedSpawnPos();
BlockPos departure = player.blockPosition();
return destinationSpawn.offset(departure.getX() - sourceSpawn.getX(),
departure.getY() - sourceSpawn.getY(), departure.getZ() - sourceSpawn.getZ());
}
/** Drop each inventory stack as an item entity at the departure point. */ /** Drop each inventory stack as an item entity at the departure point. */
private static void dropInventoryAtSource(ServerPlayer player) { private static void dropInventoryAtSource(ServerPlayer player) {
var inventory = player.getInventory(); var inventory = player.getInventory();
@@ -115,22 +176,36 @@ public final class LinkingService {
ServerPlayer player, ServerPlayer player,
ServerLevel destinationLevel, ServerLevel destinationLevel,
Vec3 position, Vec3 position,
LinkTarget target LinkTarget target,
boolean instant
) { ) {
if (ModList.get().isLoaded("immersive_portals_core")) { if (ModList.get().isLoaded("immersive_portals_core")) {
ImmersivePortalsChunkLoaders.close(player); ImmersivePortalsChunkLoaders.close(player);
ImmersivePortalsChunkLoaders.teleport( if (instant) {
player, destinationLevel, position, target.yaw(), target.pitch()); ImmersivePortalsChunkLoaders.teleport(
} else { player, destinationLevel, position, target.yaw(), target.pitch());
player.teleportTo( return;
destinationLevel, }
position.x,
position.y,
position.z,
target.yaw(),
target.pitch()
);
} }
player.teleportTo(
destinationLevel,
position.x,
position.y,
position.z,
target.yaw(),
target.pitch()
);
}
private static boolean destinationChunksLoaded(ServerLevel level, BlockPos center) {
int chunkX = center.getX() >> 4;
int chunkZ = center.getZ() >> 4;
for (int x = chunkX - 1; x <= chunkX + 1; x++) {
for (int z = chunkZ - 1; z <= chunkZ + 1; z++) {
if (!level.getChunkSource().hasChunk(x, z)) return false;
}
}
return true;
} }
static Optional<Vec3> findSafeDestination(ServerLevel level, BlockPos origin) { static Optional<Vec3> findSafeDestination(ServerLevel level, BlockPos origin) {
@@ -209,6 +284,7 @@ public final class LinkingService {
SUCCESS, SUCCESS,
MISSING_DIMENSION, MISSING_DIMENSION,
INTRA_LINKING_REQUIRED, INTRA_LINKING_REQUIRED,
INTRA_ONLY,
NO_SAFE_DESTINATION NO_SAFE_DESTINATION
} }
} }
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModEntities;
import net.minecraft.client.renderer.item.ItemProperties; import net.minecraft.client.renderer.item.ItemProperties;
import net.minecraft.client.renderer.ItemBlockRenderTypes; import net.minecraft.client.renderer.ItemBlockRenderTypes;
import net.minecraft.client.renderer.RenderType; import net.minecraft.client.renderer.RenderType;
@@ -25,6 +26,7 @@ import net.dnorberg.rivencraft.client.screen.NotebookScreen;
import net.dnorberg.rivencraft.client.screen.WritingDeskScreen; import net.dnorberg.rivencraft.client.screen.WritingDeskScreen;
import net.dnorberg.rivencraft.client.screen.BookBinderScreen; import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
import net.dnorberg.rivencraft.client.screen.InkMixerScreen; import net.dnorberg.rivencraft.client.screen.InkMixerScreen;
import net.dnorberg.rivencraft.client.screen.LinkModifierScreen;
import net.dnorberg.rivencraft.registry.ModMenus; import net.dnorberg.rivencraft.registry.ModMenus;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT) @EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
@@ -34,6 +36,7 @@ public final class ClientEvents {
@SubscribeEvent @SubscribeEvent
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) { public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
event.registerEntityRenderer(ModEntities.DROPPED_BOOK.get(), DroppedBookRenderer::new);
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new); event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new); event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new);
event.registerBlockEntityRenderer(ModBlockEntities.STAR_FISSURE.get(), StarFissureRenderer::new); event.registerBlockEntityRenderer(ModBlockEntities.STAR_FISSURE.get(), StarFissureRenderer::new);
@@ -45,6 +48,7 @@ public final class ClientEvents {
event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new); event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new);
event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new); event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new);
event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new); event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new);
event.register(ModMenus.LINK_MODIFIER.get(), LinkModifierScreen::new);
} }
@SubscribeEvent @SubscribeEvent
@@ -78,13 +82,15 @@ public final class ClientEvents {
ItemBlockRenderTypes.setRenderLayer(ModBlocks.MEDIUM_CRYSTAL_BUD.get(), RenderType.cutout()); ItemBlockRenderTypes.setRenderLayer(ModBlocks.MEDIUM_CRYSTAL_BUD.get(), RenderType.cutout());
ItemBlockRenderTypes.setRenderLayer(ModBlocks.LARGE_CRYSTAL_BUD.get(), RenderType.cutout()); ItemBlockRenderTypes.setRenderLayer(ModBlocks.LARGE_CRYSTAL_BUD.get(), RenderType.cutout());
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_CLUSTER.get(), RenderType.cutout()); ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_CLUSTER.get(), RenderType.cutout());
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CLOUD_PLATFORM.get(), RenderType.translucent()); ItemBlockRenderTypes.setRenderLayer(ModBlocks.INK_MIXER.get(), RenderType.cutout());
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_PORTAL.get(), RenderType.translucent()); ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_PORTAL.get(), RenderType.translucent());
// Immersive Portals composes the remote view before translucent blocks. // Immersive Portals composes the remote view before translucent blocks.
// Draw the opaque crystal frame in that later pass so it masks the view; // Draw the opaque crystal frame in that later pass so it masks the view;
// the portal cell itself is invisible while its live view is active. // the portal cell itself is invisible while its live view is active.
if (ModList.get().isLoaded("immersive_portals_core")) if (ModList.get().isLoaded("immersive_portals_core"))
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_BLOCK.get(), RenderType.translucent()); ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_BLOCK.get(), RenderType.translucent());
if (ModList.get().isLoaded("create"))
net.dnorberg.rivencraft.compat.RivencraftPonderGuide.register();
}); });
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem); NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem);
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock); NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
@@ -0,0 +1,51 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.model.BookModel;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.resources.ResourceLocation;
/** Renders the same open book model and textures as Rivencraft's book stands. */
public final class DroppedBookRenderer extends EntityRenderer<DroppedBookEntity> {
private static final ResourceLocation LINKING_TEXTURE = ResourceLocation.fromNamespaceAndPath(
"rivencraft", "textures/entity/linking_book.png");
private static final ResourceLocation DESCRIPTIVE_TEXTURE = ResourceLocation.fromNamespaceAndPath(
"rivencraft", "textures/entity/descriptive_book.png");
private final BookModel model;
public DroppedBookRenderer(EntityRendererProvider.Context context) {
super(context);
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
shadowRadius = 0.25F;
}
@Override
public void render(DroppedBookEntity entity, float yaw, float partialTick, PoseStack poseStack,
MultiBufferSource buffers, int packedLight) {
poseStack.pushPose();
poseStack.translate(0.0F, 0.0625F, 0.0F);
poseStack.mulPose(Axis.YP.rotationDegrees(yaw + 90.0F));
// The opened model's covers rotate toward the YZ plane. As in Legacy's
// dropped-book renderer, a Z-axis quarter-turn lays the pages skyward.
poseStack.mulPose(Axis.ZP.rotationDegrees(90.0F));
poseStack.scale(0.8F, 0.8F, 0.8F);
model.setupAnim(0.0F, 0.0F, 0.0F, 1.2F);
VertexConsumer vertices = buffers.getBuffer(RenderType.entitySolid(getTextureLocation(entity)));
model.render(poseStack, vertices, packedLight, net.minecraft.client.renderer.texture.OverlayTexture.NO_OVERLAY, -1);
poseStack.popPose();
super.render(entity, yaw, partialTick, poseStack, buffers, packedLight);
}
@Override
public ResourceLocation getTextureLocation(DroppedBookEntity entity) {
return entity.getItem().is(ModItems.DESCRIPTIVE_BOOK.get()) ? DESCRIPTIVE_TEXTURE : LINKING_TEXTURE;
}
}
@@ -27,6 +27,7 @@ import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.core.component.DataComponents;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.sounds.SoundEvents; import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.FormattedCharSequence; import net.minecraft.util.FormattedCharSequence;
@@ -48,6 +49,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
private static final Component EXPLORE_ICON = Component.literal("👁"); private static final Component EXPLORE_ICON = Component.literal("👁");
private final AgeReference age; private final AgeReference age;
private final Component displayName;
private final ResourceLocation generationProfile; private final ResourceLocation generationProfile;
private final AgeTraits traits; private final AgeTraits traits;
private final OrderedWriting writing; private final OrderedWriting writing;
@@ -68,6 +70,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
List<SymbolDisplayMetadata> writingSymbols) { List<SymbolDisplayMetadata> writingSymbols) {
super(book.getHoverName()); super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get()); age = book.get(ModDataComponents.AGE_REFERENCE.get());
displayName = book.get(DataComponents.CUSTOM_NAME);
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get()); generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
this.traits = traits; this.traits = traits;
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY); writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
@@ -82,6 +85,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
List<SymbolDisplayMetadata> writingSymbols) { List<SymbolDisplayMetadata> writingSymbols) {
super(book.getHoverName()); super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get()); age = book.get(ModDataComponents.AGE_REFERENCE.get());
displayName = book.get(DataComponents.CUSTOM_NAME);
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get()); generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
this.traits = traits; this.traits = traits;
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY); writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
@@ -245,7 +249,8 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
} }
return; return;
} }
int nameEnd = drawWrappedWithoutShadow(graphics, Component.literal(age.name()), int nameEnd = drawWrappedWithoutShadow(graphics,
displayName == null ? Component.literal(age.name()) : displayName,
x, top + 40, 110); x, top + 40, 110);
int detailsY = drawWrappedWithoutShadow(graphics, int detailsY = drawWrappedWithoutShadow(graphics,
author == null || author.isBlank() author == null || author.isBlank()
@@ -0,0 +1,183 @@
package net.dnorberg.rivencraft.client.screen;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.LinkProperties;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload;
import net.dnorberg.rivencraft.network.SetLinkModifierSeedPayload;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.PacketDistributor;
/** Legacy single-slot layout, with server-owned link options. */
public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifierMenu> {
private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/link_modifier_legacy.png");
private static final int[] FLAGS = {LinkProperties.DISARM, LinkProperties.INTRA_LINKING,
LinkProperties.INTRA_LINKING_ONLY, LinkProperties.MAINTAIN_MOMENTUM,
LinkProperties.RELATIVE_LINK, LinkProperties.DISABLE_PLATFORM};
private static final int[] IDS = {LinkModifierMenu.TOGGLE_DISARM, LinkModifierMenu.TOGGLE_INTRA,
LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM,
LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM};
private EditBox titleInput;
private EditBox seedInput;
private ItemStack lastBook = ItemStack.EMPTY;
private long lastSeed = Long.MIN_VALUE;
public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
imageWidth = 176;
imageHeight = 166;
inventoryLabelX = 8;
inventoryLabelY = 72;
}
@Override protected void init() {
super.init();
titleInput = new EditBox(font, leftPos + 80, topPos + 56, 87, 14,
Component.translatable("screen.rivencraft.link_modifier.title"));
titleInput.setMaxLength(32);
titleInput.setResponder(value -> PacketDistributor.sendToServer(
new SetLinkModifierTitlePayload(menu.containerId, value)));
addRenderableWidget(titleInput);
seedInput = new EditBox(font, leftPos + 80, topPos + 15, 87, 14,
Component.translatable("screen.rivencraft.link_modifier.seed"));
seedInput.setMaxLength(21);
seedInput.setTooltip(Tooltip.create(Component.translatable("screen.rivencraft.link_modifier.seed_warning")));
addRenderableWidget(seedInput);
refreshControls();
}
private void click(int id) {
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
}
@Override protected void containerTick() { refreshControls(); }
private void submitSeed() {
if (seedInput.visible && !seedInput.getValue().equals(Long.toString(menu.seed())))
PacketDistributor.sendToServer(new SetLinkModifierSeedPayload(menu.containerId, seedInput.getValue()));
}
@Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (seedInput.isFocused()) {
if (keyCode == 257 || keyCode == 335) submitSeed();
else if (keyCode != 256) seedInput.keyPressed(keyCode, scanCode, modifiers);
if (keyCode == 256 || keyCode == 257 || keyCode == 335) {
setFocused(null);
seedInput.setFocused(false);
}
return true;
}
if (titleInput.isFocused()) {
if (keyCode == 256) {
setFocused(null);
titleInput.setFocused(false);
} else titleInput.keyPressed(keyCode, scanCode, modifiers);
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
@Override public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (seedInput.isFocused() && (mouseX < seedInput.getX() || mouseX >= seedInput.getX() + seedInput.getWidth()
|| mouseY < seedInput.getY() || mouseY >= seedInput.getY() + seedInput.getHeight())) {
submitSeed();
setFocused(null);
seedInput.setFocused(false);
}
if (button == 0) {
for (int index = 0; index < FLAGS.length; index++) {
if (inside(mouseX, mouseY, 5 + (index / 3) * 20, 10 + (index % 3) * 20, 18, 18)) {
if (!menu.book().isEmpty()) click(IDS[index]);
return true;
}
}
if (menu.canDelete(minecraft.player)
&& inside(mouseX, mouseY, menu.awaitingDeletionConfirmation() ? 140 : 120, 32, 18, 18)) {
click(LinkModifierMenu.DELETE_AGE);
return true;
}
}
return super.mouseClicked(mouseX, mouseY, button);
}
private boolean inside(double mouseX, double mouseY, int x, int y, int width, int height) {
return mouseX >= leftPos + x && mouseX < leftPos + x + width
&& mouseY >= topPos + y && mouseY < topPos + y + height;
}
private void refreshControls() {
ItemStack book = menu.book();
if (!ItemStack.isSameItemSameComponents(book, lastBook) && !titleInput.isFocused()) {
Component name = book.get(DataComponents.CUSTOM_NAME);
titleInput.setValue(name == null ? "" : name.getString());
lastBook = book.copy();
}
boolean present = !book.isEmpty();
titleInput.setEditable(present);
seedInput.visible = menu.hasSeed();
seedInput.setEditable(menu.hasSeed());
if (seedInput.visible && !seedInput.isFocused() && (lastSeed != menu.seed()
|| !ItemStack.isSameItemSameComponents(book, lastBook))) {
lastSeed = menu.seed();
seedInput.setValue(Long.toString(lastSeed));
}
}
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
graphics.blit(BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
LinkProperties properties = menu.book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
for (int index = 0; index < FLAGS.length; index++)
square(graphics, 5 + (index / 3) * 20, 10 + (index % 3) * 20,
properties.has(FLAGS[index]), false);
if (menu.canDelete(minecraft.player)) {
square(graphics, 120, 32, menu.awaitingDeletionConfirmation(), false);
if (menu.awaitingDeletionConfirmation()) square(graphics, 140, 32, false, true);
}
}
private void square(GuiGraphics graphics, int x, int y, boolean selected, boolean danger) {
int left = leftPos + x;
int top = topPos + y;
graphics.fill(left, top, left + 18, top + 18, 0xFFAAAAAA);
graphics.fill(left + 2, top + 2, left + 17, top + 17,
danger ? 0xFFAA5555 : selected ? 0xFFBBBBBB : 0xFF777777);
}
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
}
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
renderTooltip(graphics, mouseX, mouseY);
if (!menu.book().isEmpty()) {
for (int index = 0; index < FLAGS.length; index++) {
if (inside(mouseX, mouseY, 5 + (index / 3) * 20, 10 + (index % 3) * 20, 18, 18)) {
Component label = FLAGS[index] == LinkProperties.DISABLE_PLATFORM
? Component.literal("Generate Platform: "
+ (menu.book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
.has(LinkProperties.DISABLE_PLATFORM) ? "Off" : "On"))
: LinkProperties.name(FLAGS[index]);
graphics.renderTooltip(font, label, mouseX, mouseY);
return;
}
}
}
if (menu.canDelete(minecraft.player)
&& inside(mouseX, mouseY, menu.awaitingDeletionConfirmation() ? 140 : 120, 32, 18, 18))
graphics.renderTooltip(font, Component.translatable(menu.awaitingDeletionConfirmation()
? "screen.rivencraft.link_modifier.confirm" : "screen.rivencraft.link_modifier.retire"),
mouseX, mouseY);
}
}
@@ -182,9 +182,9 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
private int displayedPageSlots() { private int displayedPageSlots() {
if (menu.getCarried().is(ModItems.PAGE.get())) { if (menu.getCarried().is(ModItems.PAGE.get())) {
return NotebookMenu.NOTEBOOK_SLOTS; return menu.pageCapacity();
} }
for (int index = NotebookMenu.NOTEBOOK_SLOTS - 1; index >= 0; index--) { for (int index = menu.pageCapacity() - 1; index >= 0; index--) {
if (!menu.getSlot(index).getItem().isEmpty()) { if (!menu.getSlot(index).getItem().isEmpty()) {
return index + 1; return index + 1;
} }
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.NotebookContents; import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata; import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
@@ -234,7 +235,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
private List<ItemStack> pages(int collectionIndex) { private List<ItemStack> pages(int collectionIndex) {
ItemStack collection = menu.getSlot(collectionIndex).getItem(); ItemStack collection = menu.getSlot(collectionIndex).getItem();
if (collection.is(ModItems.PAGE.get())) return List.of(collection); if (collection.is(ModItems.PAGE.get())) return List.of(collection);
if (collection.is(ModItems.NOTEBOOK.get())) { if (CollationFolderItem.isCollection(collection)) {
return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages(); return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
} }
return List.of(); return List.of();
@@ -301,9 +302,9 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
private boolean canMovePage(DisplayPage source, DisplayPage target) { private boolean canMovePage(DisplayPage source, DisplayPage target) {
return !source.stack().isEmpty() return !source.stack().isEmpty()
&& (menu.getSlot(source.collection()).getItem().is(ModItems.NOTEBOOK.get()) && (CollationFolderItem.isCollection(menu.getSlot(source.collection()).getItem())
|| menu.getSlot(source.collection()).getItem().is(ModItems.PAGE.get())) || menu.getSlot(source.collection()).getItem().is(ModItems.PAGE.get()))
&& (menu.getSlot(target.collection()).getItem().is(ModItems.NOTEBOOK.get()) && (CollationFolderItem.isCollection(menu.getSlot(target.collection()).getItem())
|| menu.getSlot(target.collection()).getItem().is(ModItems.PAGE.get())) || menu.getSlot(target.collection()).getItem().is(ModItems.PAGE.get()))
&& (source.collection() != target.collection() || source.index() != target.index()); && (source.collection() != target.collection() || source.index() != target.index());
} }
@@ -317,10 +318,10 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
if (!slot.mayPlace(held)) return -1; if (!slot.mayPlace(held)) return -1;
ItemStack current = slot.getItem(); ItemStack current = slot.getItem();
if (current.isEmpty()) return index; if (current.isEmpty()) return index;
if (index < WritingDeskBlockEntity.COLLECTIONS && current.is(ModItems.NOTEBOOK.get())) { if (index < WritingDeskBlockEntity.COLLECTIONS && CollationFolderItem.isCollection(current)) {
List<ItemStack> pages = current.getOrDefault( List<ItemStack> pages = current.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages(); ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
if (pages.size() < NotebookContents.MAX_PAGES || pages.stream().anyMatch(ItemStack::isEmpty)) if (pages.size() < CollationFolderItem.capacity(current) || pages.stream().anyMatch(ItemStack::isEmpty))
return index; return index;
} else if (index > WritingDeskMenu.COPY_RESULT && ItemStack.isSameItemSameComponents(current, held) } else if (index > WritingDeskMenu.COPY_RESULT && ItemStack.isSameItemSameComponents(current, held)
&& current.getCount() < slot.getMaxStackSize(held)) return index; && current.getCount() < slot.getMaxStackSize(held)) return index;
@@ -112,8 +112,9 @@ public final class RivencraftCommands {
source.sendSuccess(() -> Component.literal("Rivencraft Ages: " + ages.size()), false); source.sendSuccess(() -> Component.literal("Rivencraft Ages: " + ages.size()), false);
for (AgeDefinition age : ages) { for (AgeDefinition age : ages) {
boolean loaded = source.getServer().getLevel(AgeDimensionService.dimensionKey(age)) != null; boolean loaded = source.getServer().getLevel(AgeDimensionService.dimensionKey(age)) != null;
boolean retired = AgeRegistrySavedData.get(source.getLevel()).isDead(age.id());
source.sendSuccess(() -> Component.literal( source.sendSuccess(() -> Component.literal(
(loaded ? "[loaded] " : "[unloaded] ") (retired ? "[retired] " : loaded ? "[loaded] " : "[unloaded] ")
+ age.name() + " — " + age.dimensionId() + age.name() + " — " + age.dimensionId()
+ (age.seasonMode() == null ? "" : " (season: " + (age.seasonMode() == null ? "" : " (season: "
+ age.seasonMode().name().toLowerCase(java.util.Locale.ROOT) + ")") + age.seasonMode().name().toLowerCase(java.util.Locale.ROOT) + ")")
@@ -16,7 +16,10 @@ import qouteall.q_misc_util.dimension.DimensionIntId;
/** Isolated server bridge; this class is loaded only when Immersive Portals is installed. */ /** Isolated server bridge; this class is loaded only when Immersive Portals is installed. */
public final class ImmersivePortalsChunkLoaders { public final class ImmersivePortalsChunkLoaders {
private static final Map<ServerPlayer, ChunkLoader> LOADERS = new IdentityHashMap<>(); private static final long MAX_PREVIEW_TICKS = 20L * 60 * 5;
private static final Map<ServerPlayer, PreviewLoader> LOADERS = new IdentityHashMap<>();
private record PreviewLoader(ChunkLoader loader, long expiresAt) {}
private ImmersivePortalsChunkLoaders() { private ImmersivePortalsChunkLoaders() {
} }
@@ -35,29 +38,40 @@ public final class ImmersivePortalsChunkLoaders {
ChunkLoader loader = new ChunkLoader(level.dimension(), centerChunkX, centerChunkZ, 8); ChunkLoader loader = new ChunkLoader(level.dimension(), centerChunkX, centerChunkZ, 8);
PortalAPI.addChunkLoaderForPlayer(player, loader); PortalAPI.addChunkLoaderForPlayer(player, loader);
LOADERS.put(player, loader); LOADERS.put(player, new PreviewLoader(loader, player.getServer().getTickCount() + MAX_PREVIEW_TICKS));
ImmPtlChunkTracking.immediatelyUpdateForPlayer(player); ImmPtlChunkTracking.immediatelyUpdateForPlayer(player);
} }
public static synchronized void close(ServerPlayer player) { public static synchronized void close(ServerPlayer player) {
ChunkLoader loader = LOADERS.remove(player); PreviewLoader preview = LOADERS.remove(player);
if (loader != null) { if (preview != null) {
PortalAPI.removeChunkLoaderForPlayer(player, loader); PortalAPI.removeChunkLoaderForPlayer(player, preview.loader());
} }
} }
/** Fail-safe for a lost close packet: no book preview can hold chunks indefinitely. */
public static synchronized void expire(MinecraftServer server) {
if (server.getTickCount() % 20 != 0) return;
LOADERS.entrySet().removeIf(entry -> {
if (server.getTickCount() < entry.getValue().expiresAt()
&& server.getLevel(entry.getValue().loader().dimension()) != null) return false;
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue().loader());
return true;
});
}
public static synchronized void closeForDimension(ResourceKey<Level> dimension) { public static synchronized void closeForDimension(ResourceKey<Level> dimension) {
LOADERS.entrySet().removeIf(entry -> { LOADERS.entrySet().removeIf(entry -> {
if (!entry.getValue().dimension().equals(dimension)) { if (!entry.getValue().loader().dimension().equals(dimension)) {
return false; return false;
} }
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue()); PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue().loader());
return true; return true;
}); });
} }
public static synchronized void closeAll() { public static synchronized void closeAll() {
LOADERS.forEach(PortalAPI::removeChunkLoaderForPlayer); LOADERS.forEach((player, preview) -> PortalAPI.removeChunkLoaderForPlayer(player, preview.loader()));
LOADERS.clear(); LOADERS.clear();
} }
@@ -23,12 +23,12 @@ public final class ImmersivePortalsCrystalView {
private ImmersivePortalsCrystalView() {} private ImmersivePortalsCrystalView() {}
public static void activate(ServerLevel source, BlockPos receptacle, public static void activate(ServerLevel source, BlockPos receptacle,
CrystalPortalNetwork.Plan plan, LinkTarget target) { CrystalPortalNetwork.Plan plan, LinkTarget target, boolean requireFooting) {
remove(source, receptacle); remove(source, receptacle);
ServerLevel destinationLevel = source.getServer().getLevel(target.location().dimension()); ServerLevel destinationLevel = source.getServer().getLevel(target.location().dimension());
if (destinationLevel == null) return; if (destinationLevel == null) return;
// This is the same safe-location search used by portal book travel. // This is the same safe-location search used by portal book travel.
var arrival = LinkingService.resolveDestination(destinationLevel, target.location().pos(), false); var arrival = LinkingService.resolveDestination(destinationLevel, target.location().pos(), requireFooting);
if (arrival.isEmpty()) return; if (arrival.isEmpty()) return;
Vec3 arrivalEye = arrival.get().add(0, 1.62, 0); Vec3 arrivalEye = arrival.get().add(0, 1.62, 0);
for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(source, plan)) { for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(source, plan)) {
@@ -46,11 +46,15 @@ public final class ImmersivePortalsCrystalView {
&& (entity.portalTag.equals(tag) || entity.portalTag.equals(oldTag)))) portal.discard(); && (entity.portalTag.equals(tag) || entity.portalTag.equals(oldTag)))) portal.discard();
} }
public static boolean exists(ServerLevel source, BlockPos receptacle) { public static boolean complete(ServerLevel source, BlockPos receptacle,
CrystalPortalNetwork.Plan plan, LinkTarget target) {
String tag = tag(receptacle); String tag = tag(receptacle);
return !source.getEntitiesOfClass(Portal.class, var views = source.getEntitiesOfClass(Portal.class,
new AABB(receptacle).inflate(CrystalPortalNetwork.MAX_RADIUS + 2), new AABB(receptacle).inflate(CrystalPortalNetwork.MAX_RADIUS + 2),
entity -> tag.equals(entity.portalTag)).isEmpty(); entity -> tag.equals(entity.portalTag));
int expected = CrystalPortalSurfaces.group(source, plan).size() * 2;
return expected > 0 && views.size() == expected && views.stream()
.allMatch(view -> target.location().dimension().equals(view.getDestDim()));
} }
private static void spawn(ServerLevel source, BlockPos receptacle, CrystalPortalSurfaces.Surface surface, private static void spawn(ServerLevel source, BlockPos receptacle, CrystalPortalSurfaces.Surface surface,
@@ -0,0 +1,108 @@
package net.dnorberg.rivencraft.compat;
import net.createmod.ponder.api.PonderPalette;
import net.createmod.ponder.api.registration.PonderPlugin;
import net.createmod.ponder.api.registration.PonderSceneRegistrationHelper;
import net.createmod.ponder.api.scene.SceneBuilder;
import net.createmod.ponder.api.scene.SceneBuildingUtil;
import net.createmod.ponder.foundation.PonderIndex;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
import net.dnorberg.rivencraft.block.CrystalPortalBlock;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
/** Create-only Ponder registration; this class is never loaded without Create. */
public final class RivencraftPonderGuide implements PonderPlugin {
private static final ResourceLocation DEPOT_TEMPLATE = ResourceLocation.fromNamespaceAndPath("create", "depot");
private RivencraftPonderGuide() {}
public static void register() {
PonderIndex.addPlugin(new RivencraftPonderGuide());
}
@Override
public String getModId() {
return Rivencraft.MOD_ID;
}
@Override
public void registerScenes(PonderSceneRegistrationHelper<ResourceLocation> helper) {
// Create is already present when this plugin is registered. Its small
// depot schematic supplies an emptyable 6×4×6 canvas, so Rivencraft
// does not need to ship a separate binary Ponder template.
helper.forComponents(ModItems.BOOK_RECEPTACLE.getId(), ModItems.CRYSTAL_BLOCK.getId())
.addStoryBoard(DEPOT_TEMPLATE, RivencraftPonderGuide::crystalPortal);
}
private static void crystalPortal(SceneBuilder scene, SceneBuildingUtil util) {
scene.title("crystal_portal", "Building a Crystal Portal");
scene.configureBasePlate(0, 0, 6);
scene.scaleSceneView(0.85F);
scene.showBasePlate();
scene.world().setBlocks(util.select().fromTo(0, 1, 0, 5, 3, 5),
Blocks.AIR.defaultBlockState(), false);
scene.idle(10);
BlockPos center = util.grid().at(3, 2, 3);
scene.overlay().showText(65).text("Build a closed crystal frame with an open center.")
.pointAt(util.vector().centerOf(center)).placeNearTarget();
for (int x = 2; x <= 4; x++) {
for (int y = 1; y <= 3; y++) {
if (x == 3 && y == 2) continue;
BlockPos pos = util.grid().at(x, y, 3);
scene.world().setBlock(pos, ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), false);
scene.world().showSection(util.select().position(pos), Direction.DOWN);
scene.idle(3);
}
}
scene.idle(30);
BlockPos receptacle = util.grid().at(2, 1, 2);
scene.overlay().showText(70).text("Attach a Book Receptacle to one crystal, facing outward.")
.pointAt(util.vector().centerOf(receptacle)).placeNearTarget();
scene.world().setBlock(receptacle, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), false);
scene.world().showSection(util.select().position(receptacle), Direction.DOWN);
scene.idle(75);
scene.overlay().showText(65).text("Insert a bound Linking or Descriptive Book to activate the portal.")
.colored(PonderPalette.INPUT).pointAt(util.vector().centerOf(receptacle)).placeNearTarget();
scene.overlay().showControls(util.vector().centerOf(receptacle),
net.createmod.catnip.math.Pointing.RIGHT, 55)
.rightClick().withItem(new ItemStack(ModItems.LINKING_BOOK.get()));
scene.idle(65);
scene.world().setBlock(center, ModBlocks.CRYSTAL_PORTAL.get().defaultBlockState()
.setValue(CrystalPortalBlock.AXIS, Direction.Axis.Z), false);
scene.world().showSection(util.select().position(center), Direction.DOWN);
scene.effects().indicateSuccess(center);
scene.overlay().showText(80).text("Step through the portal. Remove the book to close it.")
.colored(PonderPalette.OUTPUT).pointAt(util.vector().centerOf(center)).placeNearTarget();
scene.idle(20);
var parrot = scene.world().createEntity(world -> {
var bird = EntityType.PARROT.create(world);
bird.setNoAi(true);
bird.setPos(3.5, 2.05, 1.5);
bird.setYRot(0);
return bird;
});
scene.idle(10);
for (int step = 1; step <= 7; step++) {
double z = 1.5 + step * 0.42;
scene.world().modifyEntity(parrot, bird -> bird.setPos(3.5, 2.05, z));
scene.idle(5);
}
scene.world().modifyEntity(parrot, bird -> bird.discard());
scene.effects().indicateSuccess(center);
scene.idle(20);
scene.markAsFinished();
}
}
@@ -75,7 +75,7 @@ public final class RivencraftConfig {
"Controls automatic Return Link creation for Descriptive Book travel.", "Controls automatic Return Link creation for Descriptive Book travel.",
"ON_CREATION creates one link when an Age is first reached; ALWAYS creates one on every arrival; DISABLED creates none." "ON_CREATION creates one link when an Age is first reached; ALWAYS creates one on every arrival; DISABLED creates none."
) )
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION); .defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
builder.pop(); builder.pop();
builder.push("libraries"); builder.push("libraries");
LIBRARY_LOOT_MODE = builder.comment( LIBRARY_LOOT_MODE = builder.comment(
@@ -0,0 +1,62 @@
package net.dnorberg.rivencraft.entity;
import net.dnorberg.rivencraft.registry.ModEntities;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
/** A recoverable, model-rendered book left behind by handheld linking. */
public final class DroppedBookEntity extends ItemEntity {
private static final String DECAY_TICKS_TAG = "BookDecayTicks";
private static final int SLOW_DECAY_INTERVAL = 10_000;
private int decayTicks;
public DroppedBookEntity(EntityType<DroppedBookEntity> type, Level level) {
super(type, level);
}
public DroppedBookEntity(Level level, Vec3 position, ItemStack stack) {
this(ModEntities.DROPPED_BOOK.get(), level);
setPos(position.x, position.y + 0.25, position.z);
setDeltaMovement(Vec3.ZERO);
setItem(stack);
setUnlimitedLifetime();
setPickUpDelay(20);
}
/** Vanilla's pickup particle calls ItemEntity.copy() and renders the returned entity. */
@Override
public DroppedBookEntity copy() {
DroppedBookEntity copy = new DroppedBookEntity(ModEntities.DROPPED_BOOK.get(), level());
copy.copyPosition(this);
copy.setItem(getItem().copy());
copy.setUnlimitedLifetime();
return copy;
}
@Override
public void tick() {
super.tick();
if (level().isClientSide || isRemoved()) return;
if (++decayTicks >= SLOW_DECAY_INTERVAL) {
decayTicks = 0;
hurt(damageSources().starve(), 1.0F);
}
if (!isRemoved() && isInWaterOrRain()) hurt(damageSources().drown(), 1.0F);
}
@Override
public void addAdditionalSaveData(CompoundTag tag) {
super.addAdditionalSaveData(tag);
tag.putInt(DECAY_TICKS_TAG, decayTicks);
}
@Override
public void readAdditionalSaveData(CompoundTag tag) {
super.readAdditionalSaveData(tag);
decayTicks = Math.max(0, Math.min(tag.getInt(DECAY_TICKS_TAG), SLOW_DECAY_INTERVAL - 1));
}
}
@@ -1,10 +1,13 @@
package net.dnorberg.rivencraft.event; package net.dnorberg.rivencraft.event;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders; import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.neoforged.fml.ModList; import net.neoforged.fml.ModList;
import net.neoforged.neoforge.event.entity.player.PlayerEvent; import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
/** Server-authoritative cleanup for resources that exist only while a preview is active. */ /** Server-authoritative cleanup for resources that exist only while a preview is active. */
public final class AgeLifecycleEvents { public final class AgeLifecycleEvents {
@@ -12,6 +15,10 @@ public final class AgeLifecycleEvents {
} }
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) { public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
PortalTravelCooldown.forget(player);
PortalTravelQueue.forget(player);
}
if (event.getEntity() instanceof ServerPlayer player if (event.getEntity() instanceof ServerPlayer player
&& ModList.get().isLoaded("immersive_portals_core")) { && ModList.get().isLoaded("immersive_portals_core")) {
ImmersivePortalsChunkLoaders.close(player); ImmersivePortalsChunkLoaders.close(player);
@@ -19,8 +26,16 @@ public final class AgeLifecycleEvents {
} }
public static void onServerStopping(ServerStoppingEvent event) { public static void onServerStopping(ServerStoppingEvent event) {
PortalTravelCooldown.clear();
PortalTravelQueue.clear();
if (ModList.get().isLoaded("immersive_portals_core")) { if (ModList.get().isLoaded("immersive_portals_core")) {
ImmersivePortalsChunkLoaders.closeAll(); ImmersivePortalsChunkLoaders.closeAll();
} }
} }
public static void onServerTickPost(ServerTickEvent.Post event) {
PortalTravelQueue.process(event.getServer());
if (ModList.get().isLoaded("immersive_portals_core"))
ImmersivePortalsChunkLoaders.expire(event.getServer());
}
} }
@@ -16,7 +16,10 @@ import net.minecraft.core.Direction;
import net.minecraft.core.GlobalPos; import net.minecraft.core.GlobalPos;
import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@@ -29,14 +32,17 @@ public final class CrystalPortalGameTests {
public static void completeFrameActivatesAndReleasesBook(GameTestHelper helper) { public static void completeFrameActivatesAndReleasesBook(GameTestHelper helper) {
var level = helper.getLevel(); var level = helper.getLevel();
for (int x = 0; x < 3; x++) for (int y = 1; y < 4; y++) { for (int x = 0; x < 3; x++) for (int y = 1; y < 4; y++) {
if (x == 1 && y == 2) continue; if (x == 1 && y == 2) {
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
continue;
}
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3); ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
} }
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0)); BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState() level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3); .setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, holderPos); CrystalPortalNetwork.Plan plan = planLoaded(level, holderPos);
helper.assertTrue(plan.valid(), "A closed crystal frame must form a portal"); helper.assertTrue(plan.valid(), "A closed crystal frame must form a portal");
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos); BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get())); holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
@@ -44,12 +50,34 @@ public final class CrystalPortalGameTests {
holder.setDestination(destination); holder.setDestination(destination);
BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState()); BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState());
restored.loadWithComponents(holder.saveWithoutMetadata(level.registryAccess()), level.registryAccess()); restored.loadWithComponents(holder.saveWithoutMetadata(level.registryAccess()), level.registryAccess());
helper.assertTrue(restored.getBook().is(ModItems.LINKING_BOOK.get()),
"The book itself must survive a save/reload cycle");
helper.assertValueEqual(restored.destination(), destination, helper.assertValueEqual(restored.destination(), destination,
"The portal destination must survive a save/reload cycle"); "The portal destination must survive a save/reload cycle");
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan), "Portal placement must succeed"); helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan), "Portal placement must succeed");
BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1)); BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1));
helper.assertTrue(level.getBlockEntity(center) instanceof CrystalPortalBlockEntity portal helper.assertTrue(level.getBlockEntity(center) instanceof CrystalPortalBlockEntity portal
&& holderPos.equals(portal.receptacle()), "The portal must point to its one book holder"); && holderPos.equals(portal.receptacle()), "The portal must point to its one book holder");
CrystalPortalBlockEntity savedPortal = (CrystalPortalBlockEntity) level.getBlockEntity(center);
CrystalPortalBlockEntity restoredPortal = new CrystalPortalBlockEntity(center, savedPortal.getBlockState());
CompoundTag savedPortalTag = savedPortal.saveWithoutMetadata(level.registryAccess());
helper.assertTrue(savedPortalTag.contains("Receptacle"), "Portal owner must serialize: " + savedPortalTag);
restoredPortal.loadWithComponents(savedPortalTag,
level.registryAccess());
helper.assertTrue(restoredPortal.receptacle() != null, "Portal owner must deserialize: " + savedPortalTag);
helper.assertValueEqual(restoredPortal.receptacle(), holderPos,
"The portal-to-receptacle pointer must survive a full chunk save");
helper.assertTrue(CrystalPortalNetwork.reconcile(level, holderPos),
"A saved portal must recover without duplicating cells");
CrystalPortalNetwork.setLiveView(level, plan, true);
CrystalPortalBlockEntity livePortal = (CrystalPortalBlockEntity) level.getBlockEntity(center);
helper.assertValueEqual(livePortal.receptacle(), holderPos,
"Switching to the live view must retain the portal's book holder");
helper.assertTrue(livePortal.saveWithoutMetadata(level.registryAccess()).contains("Receptacle"),
"The live-view portal must persist its holder pointer to disk");
CrystalPortalNetwork.setLiveView(level, plan, false);
helper.assertValueEqual(((CrystalPortalBlockEntity) level.getBlockEntity(center)).receptacle(), holderPos,
"Switching back to the fallback texture must retain the holder");
helper.assertValueEqual(level.getBlockState(center).getValue(CrystalPortalBlock.AXIS), Direction.Axis.Z, helper.assertValueEqual(level.getBlockState(center).getValue(CrystalPortalBlock.AXIS), Direction.Axis.Z,
"A north/south opening must only show its two player-facing sides"); "A north/south opening must only show its two player-facing sides");
CrystalPortalNetwork.shutdown(level, holderPos); CrystalPortalNetwork.shutdown(level, holderPos);
@@ -57,6 +85,33 @@ public final class CrystalPortalGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void backgroundRepairDoesNotRecreateMissingCells(GameTestHelper helper) {
var level = helper.getLevel();
for (int x = 0; x < 3; x++) for (int y = 1; y < 4; y++) {
if (x == 1 && y == 2) {
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
continue;
}
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
}
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
"Portal must activate before the repair check");
BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1));
level.removeBlock(center, false);
helper.assertTrue(!CrystalPortalNetwork.reconcile(level, holderPos),
"An unattended repair must not place a new cell across a protection boundary");
helper.assertTrue(level.getBlockState(center).isAir() && !holder.getBook().isEmpty(),
"A broken portal stays shut down while its one stored book remains retrievable");
helper.succeed();
}
private static BlockPos centerTarget(GameTestHelper helper) { private static BlockPos centerTarget(GameTestHelper helper) {
return helper.absolutePos(new BlockPos(1, 2, 1)); return helper.absolutePos(new BlockPos(1, 2, 1));
} }
@@ -65,7 +120,10 @@ public final class CrystalPortalGameTests {
public static void eastWestOpeningOrientsFallbackSurface(GameTestHelper helper) { public static void eastWestOpeningOrientsFallbackSurface(GameTestHelper helper) {
var level = helper.getLevel(); var level = helper.getLevel();
for (int z = 1; z <= 3; z++) for (int y = 1; y <= 3; y++) { for (int z = 1; z <= 3; z++) for (int y = 1; y <= 3; y++) {
if (z == 2 && y == 2) continue; if (z == 2 && y == 2) {
level.setBlock(helper.absolutePos(new BlockPos(1, y, z)), Blocks.AIR.defaultBlockState(), 3);
continue;
}
level.setBlock(helper.absolutePos(new BlockPos(1, y, z)), level.setBlock(helper.absolutePos(new BlockPos(1, y, z)),
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3); ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
} }
@@ -74,7 +132,7 @@ public final class CrystalPortalGameTests {
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3); .setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos)) ((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
.setBook(new ItemStack(ModItems.LINKING_BOOK.get())); .setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, CrystalPortalNetwork.plan(level, holderPos)), helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
"The east/west crystal opening must activate"); "The east/west crystal opening must activate");
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 2, 2))) helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 2, 2)))
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.X, .getValue(CrystalPortalBlock.AXIS), Direction.Axis.X,
@@ -86,7 +144,10 @@ public final class CrystalPortalGameTests {
public static void horizontalOpeningOrientsFallbackSurface(GameTestHelper helper) { public static void horizontalOpeningOrientsFallbackSurface(GameTestHelper helper) {
var level = helper.getLevel(); var level = helper.getLevel();
for (int x = 0; x < 3; x++) for (int z = 0; z < 3; z++) { for (int x = 0; x < 3; x++) for (int z = 0; z < 3; z++) {
if (x == 1 && z == 1) continue; if (x == 1 && z == 1) {
level.setBlock(helper.absolutePos(new BlockPos(x, 1, z)), Blocks.AIR.defaultBlockState(), 3);
continue;
}
level.setBlock(helper.absolutePos(new BlockPos(x, 1, z)), level.setBlock(helper.absolutePos(new BlockPos(x, 1, z)),
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3); ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
} }
@@ -95,7 +156,7 @@ public final class CrystalPortalGameTests {
.setValue(BookReceptacleBlock.FACING, Direction.UP), 3); .setValue(BookReceptacleBlock.FACING, Direction.UP), 3);
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos)) ((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
.setBook(new ItemStack(ModItems.LINKING_BOOK.get())); .setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, CrystalPortalNetwork.plan(level, holderPos)), helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
"The horizontal crystal opening must activate"); "The horizontal crystal opening must activate");
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 1, 1))) helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 1, 1)))
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.Y, .getValue(CrystalPortalBlock.AXIS), Direction.Axis.Y,
@@ -111,7 +172,7 @@ public final class CrystalPortalGameTests {
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3); ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState() level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3); .setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
helper.assertTrue(!CrystalPortalNetwork.plan(level, holderPos).valid(), helper.assertTrue(!planLoaded(level, holderPos).valid(),
"An incomplete frame must not take a book or leave portal cells"); "An incomplete frame must not take a book or leave portal cells");
helper.succeed(); helper.succeed();
} }
@@ -120,28 +181,89 @@ public final class CrystalPortalGameTests {
public static void multiCellOpeningHasOneContinuousView(GameTestHelper helper) { public static void multiCellOpeningHasOneContinuousView(GameTestHelper helper) {
var level = helper.getLevel(); var level = helper.getLevel();
for (int x = 0; x < 3; x++) for (int y = 1; y < 5; y++) { for (int x = 0; x < 3; x++) for (int y = 1; y < 5; y++) {
if (x == 1 && (y == 2 || y == 3)) continue; if (x == 1 && (y == 2 || y == 3)) {
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
continue;
}
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3); ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
} }
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0)); BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState() level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3); .setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, holderPos); CrystalPortalNetwork.Plan plan = planLoaded(level, holderPos);
helper.assertValueEqual(plan.portalCells().size(), 2, "The opening contains two portal cells"); helper.assertValueEqual(plan.portalCells().size(), 2, "The opening contains two portal cells");
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan),
"The two-cell opening must activate before its live-view grouping is inspected");
helper.assertValueEqual(CrystalPortalSurfaces.group(level, plan).size(), 1, helper.assertValueEqual(CrystalPortalSurfaces.group(level, plan).size(), 1,
"Both cells must share one coherent Immersive Portals view"); "Both cells must share one coherent Immersive Portals view");
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty") @GameTest(template = "empty")
public static void savedCooldownFromPreviousServerCannotBlockPortal(GameTestHelper helper) { public static void largeOpeningSurvivesAtomicActivation(GameTestHelper helper) {
helper.assertTrue(!PortalTravelCooldown.blocked(5, -1), var level = helper.getLevel();
"A player with no prior portal use must link immediately"); for (int x = 0; x < 5; x++) for (int y = 1; y < 6; y++) {
helper.assertTrue(!PortalTravelCooldown.blocked(5, 12000), if (x > 0 && x < 4 && y > 1 && y < 5) {
"A saved tick from the previous server session must not block travel"); level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
helper.assertTrue(PortalTravelCooldown.blocked(105, 100), continue;
"A recent portal use in the current session must still prevent loops"); }
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
}
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
CrystalPortalNetwork.Plan plan = planLoaded(level, holderPos);
helper.assertValueEqual(plan.portalCells().size(), 9, "The large frame has a 3×3 opening");
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan),
"The complete opening must activate");
for (BlockPos pos : plan.portalCells()) {
helper.assertTrue(level.getBlockState(pos).is(ModBlocks.CRYSTAL_PORTAL.get())
&& level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
&& holderPos.equals(portal.receptacle()),
"Activation must retain every cell with the same book holder");
}
CrystalPortalNetwork.setLiveView(level, plan, true);
for (BlockPos pos : plan.portalCells()) {
((CrystalPortalBlockEntity) level.getBlockEntity(pos))
.loadWithComponents(new CompoundTag(), level.registryAccess());
}
helper.assertTrue(CrystalPortalNetwork.recoverSavedOwners(level, holderPos),
"An intact saved surface can recover its lost holder pointers without placing blocks");
for (BlockPos pos : plan.portalCells()) {
CrystalPortalBlockEntity portal = (CrystalPortalBlockEntity) level.getBlockEntity(pos);
helper.assertValueEqual(portal.receptacle(), holderPos,
"Every recovered cell must point to the book holder");
}
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void portalCooldownIsIndependentAndSessionOnly(GameTestHelper helper) {
var first = helper.makeMockServerPlayerInLevel();
var second = helper.makeMockServerPlayerInLevel();
helper.assertTrue(!PortalTravelCooldown.blocked(5, -1),
"A player with no prior portal use must link immediately");
PortalTravelCooldown.mark(first, 100);
helper.assertTrue(PortalTravelCooldown.blocked(first, 105)
&& !PortalTravelCooldown.blocked(second, 105),
"One player's loop protection must not block another player");
PortalTravelCooldown.clear();
helper.assertTrue(!PortalTravelCooldown.blocked(first, 105),
"A new server session must never inherit a saved portal cooldown");
helper.succeed();
}
private static CrystalPortalNetwork.Plan planLoaded(ServerLevel level, BlockPos holderPos) {
int chunkX = holderPos.getX() >> 4;
int chunkZ = holderPos.getZ() >> 4;
for (int x = chunkX - 1; x <= chunkX + 1; x++)
for (int z = chunkZ - 1; z <= chunkZ + 1; z++) level.getChunk(x, z);
return CrystalPortalNetwork.plan(level, holderPos);
}
} }
@@ -0,0 +1,122 @@
package net.dnorberg.rivencraft.gametest;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class Milestone7AdvancementGameTests {
private Milestone7AdvancementGameTests() {}
@GameTest(template = "empty")
public static void progressionAndRecipeUnlocksLoad(GameTestHelper helper) {
var registry = helper.getLevel().getServer().getAdvancements();
for (String path : List.of("root", "page", "linking_book", "link_panel",
"copy_page", "bind_age", "enter_age", "open_portal")) {
helper.assertTrue(registry.get(id("progression/" + path)) != null,
"Progression advancement must load: " + path);
}
for (String path : List.of("book_binder", "book_receptacle", "crystal_block",
"ink_mixer", "ink_vial", "notebook", "writing_desk")) {
helper.assertTrue(registry.get(id("recipes/misc/" + path)) != null,
"Recipe-book unlock must load: " + path);
}
var player = helper.makeMockServerPlayerInLevel();
for (String path : List.of("book_binder", "ink_mixer", "notebook", "writing_desk")) {
player.getAdvancements().award(registry.get(id("recipes/misc/" + path)), "has_material");
helper.assertTrue(player.getRecipeBook().contains(id(path)),
"Discovering the material must unlock the " + path + " recipe");
}
helper.succeed();
}
@GameTest(template = "empty")
public static void actionAwardsAreServerOwnedAndPerPlayer(GameTestHelper helper) {
var first = helper.makeMockServerPlayerInLevel();
var second = helper.makeMockServerPlayerInLevel();
var advancement = helper.getLevel().getServer().getAdvancements().get(id("progression/copy_page"));
helper.assertFalse(first.getAdvancements().getOrStartProgress(advancement).isDone(),
"Opening a menu or preview must not award a copied Page");
helper.assertFalse(second.getAdvancements().getOrStartProgress(advancement).isDone(),
"Another player must not inherit the advancement");
ProgressionAdvancements.awardAgeTravel(first);
var travel = helper.getLevel().getServer().getAdvancements().get(id("progression/enter_age"));
helper.assertFalse(first.getAdvancements().getOrStartProgress(travel).isDone(),
"Overworld travel or a preview must not count as entering an Age");
ProgressionAdvancements.award(first, ProgressionAdvancements.COPY_PAGE);
helper.assertTrue(first.getAdvancements().getOrStartProgress(advancement).isDone(),
"The server must award the player who completed the action");
helper.assertFalse(second.getAdvancements().getOrStartProgress(advancement).isDone(),
"Advancement progress must remain per-player in multiplayer");
helper.succeed();
}
@GameTest(template = "empty")
public static void mixerPreviewDoesNotAwardButTakingPanelDoes(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.INK_MIXER.get().defaultBlockState(), 3);
var mixer = (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos);
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER));
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = nearbyPlayer(helper, pos);
var menu = new InkMixerMenu(1, player.getInventory(), mixer);
helper.assertFalse(done(player, ProgressionAdvancements.LINK_PANEL),
"Opening the Ink Mixer must not award its milestone");
menu.clicked(InkMixerMenu.RESULT, 0, ClickType.PICKUP, player);
helper.assertTrue(done(player, ProgressionAdvancements.LINK_PANEL),
"Taking the completed Link Panel must award its milestone");
helper.succeed();
}
@GameTest(template = "empty")
public static void deskPreviewDoesNotAwardButCopyingDoes(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createSymbolPage(new SymbolReference(id("terrain/standard"))));
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = nearbyPlayer(helper, pos);
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertFalse(done(player, ProgressionAdvancements.COPY_PAGE),
"Opening the Writing Desk must not award a copied Page");
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ClickType.PICKUP, player);
helper.assertTrue(done(player, ProgressionAdvancements.COPY_PAGE),
"Taking a copied Page must award its milestone");
helper.succeed();
}
private static ServerPlayer nearbyPlayer(GameTestHelper helper, BlockPos pos) {
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
return player;
}
private static boolean done(ServerPlayer player, String path) {
var advancement = player.getServer().getAdvancements().get(id("progression/" + path));
return player.getAdvancements().getOrStartProgress(advancement).isDone();
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
}
@@ -0,0 +1,105 @@
package net.dnorberg.rivencraft.gametest;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
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.RecipeType;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Data-driven Milestone 7 crafting checks; every result is obtained from the loaded recipe manager. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class Milestone7CraftingGameTests {
private Milestone7CraftingGameTests() {}
@GameTest(template = "empty")
public static void crystalProgressionMatchesLegacyCounts(GameTestHelper helper) {
var level = helper.getLevel();
ItemStack shard = new ItemStack(ModItems.CRYSTAL_SHARD.get());
CraftingInput crystalInput = CraftingInput.of(2, 2, List.of(shard, shard, shard, shard));
var crystalRecipe = level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, crystalInput, level)
.orElseThrow();
helper.assertValueEqual(crystalRecipe.id(), id("crystal_block"),
"Four shards must craft a Crystal Block");
ItemStack crystal = crystalRecipe.value().assemble(crystalInput, level.registryAccess());
helper.assertTrue(crystal.is(ModItems.CRYSTAL_BLOCK.get()) && crystal.getCount() == 1,
"The shard recipe must yield one Crystal Block");
List<ItemStack> ring = new ArrayList<>();
for (int slot = 0; slot < 9; slot++) ring.add(slot == 4 ? ItemStack.EMPTY : crystal.copy());
CraftingInput receptacleInput = CraftingInput.of(3, 3, ring);
var receptacleRecipe = level.getRecipeManager()
.getRecipeFor(RecipeType.CRAFTING, receptacleInput, level).orElseThrow();
helper.assertValueEqual(receptacleRecipe.id(), id("book_receptacle"),
"The Legacy eight-block ring must craft the Receptacle");
ItemStack result = receptacleRecipe.value().assemble(receptacleInput, level.registryAccess());
helper.assertTrue(result.is(ModItems.BOOK_RECEPTACLE.get()) && result.getCount() == 1,
"One ring must yield exactly one Book Receptacle");
helper.assertTrue(receptacleRecipe.value().getRemainingItems(receptacleInput).stream()
.allMatch(ItemStack::isEmpty), "Crystal crafting must not duplicate source blocks as remainders");
ring.set(4, crystal.copy());
helper.assertTrue(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,
CraftingInput.of(3, 3, ring), level).isEmpty(),
"A filled center must not match the Book Receptacle ring");
helper.succeed();
}
@GameTest(template = "empty")
public static void bucketInkReturnsItsContainer(GameTestHelper helper) {
var level = helper.getLevel();
ItemStack dye = new ItemStack(Items.BLACK_DYE);
CraftingInput bucketInput = CraftingInput.of(2, 2, List.of(
dye, dye, new ItemStack(Items.GLASS_BOTTLE), new ItemStack(Items.WATER_BUCKET)));
var bucketRecipe = level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, bucketInput, level)
.orElseThrow();
helper.assertValueEqual(bucketRecipe.id(), id("ink_vial"), "Bucket ink recipe must remain registered");
helper.assertTrue(bucketRecipe.value().assemble(bucketInput, level.registryAccess())
.is(ModItems.INK_VIAL.get()), "Bucket recipe must yield an Ink Vial");
helper.assertTrue(bucketRecipe.value().getRemainingItems(bucketInput).stream()
.anyMatch(stack -> stack.is(Items.BUCKET)), "The water bucket must return an empty bucket");
helper.succeed();
}
@GameTest(template = "empty")
public static void recipeUnlocksAreLoadedAndBlankPagesStayUncraftable(GameTestHelper helper) {
var level = helper.getLevel();
for (String path : List.of("crystal_block", "book_receptacle", "ink_vial")) {
helper.assertTrue(level.getServer().getAdvancements().get(id("recipes/misc/" + path)) != null,
"Recipe unlock must load for " + path);
}
var player = helper.makeMockServerPlayerInLevel();
var shardUnlock = level.getServer().getAdvancements().get(id("recipes/misc/crystal_block"));
player.getAdvancements().award(shardUnlock, "has_crystal_shard");
helper.assertTrue(player.getRecipeBook().contains(id("crystal_block")),
"Discovering a shard must unlock the Crystal Block recipe");
var receptacleUnlock = level.getServer().getAdvancements().get(id("recipes/misc/book_receptacle"));
player.getAdvancements().award(receptacleUnlock, "has_crystal_block");
helper.assertTrue(player.getRecipeBook().contains(id("book_receptacle")),
"Discovering a Crystal Block must unlock the Receptacle recipe");
CraftingInput blankInput = CraftingInput.of(2, 2, List.of(
new ItemStack(Items.PAPER), new ItemStack(ModItems.INK_VIAL.get()),
ItemStack.EMPTY, ItemStack.EMPTY));
helper.assertTrue(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, blankInput, level).isEmpty(),
"Blank Pages and Link Panels must not become grid-craftable");
ItemStack panel = PageItem.createLinkPanelPage();
CraftingInput linkInput = CraftingInput.of(2, 1, List.of(panel, new ItemStack(Items.LEATHER)));
helper.assertValueEqual(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, linkInput, level)
.orElseThrow().id(), id("linking_book"),
"Link Panel crafting remains limited to the established Linking Book recipe");
helper.succeed();
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
}
@@ -0,0 +1,51 @@
package net.dnorberg.rivencraft.gametest;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.LinkProperties;
import net.dnorberg.rivencraft.book.LinkVisualEffects;
import net.dnorberg.rivencraft.registry.ModSounds;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class Milestone7PolishGameTests {
private Milestone7PolishGameTests() {}
@GameTest(template = "empty")
public static void modifierFlagsKeepLegacyIntraRules(GameTestHelper helper) {
LinkProperties linking = new LinkProperties(LinkProperties.DISARM | LinkProperties.INTRA_LINKING
| LinkProperties.INTRA_LINKING_ONLY | LinkProperties.RELATIVE_LINK
| LinkProperties.DISABLE_PLATFORM);
helper.assertTrue(linking.has(LinkProperties.INTRA_LINKING_ONLY)
&& linking.has(LinkProperties.RELATIVE_LINK)
&& linking.has(LinkProperties.DISABLE_PLATFORM),
"The new link options must retain independent flag values");
LinkProperties descriptive = linking.forDescriptiveBook();
helper.assertTrue(!descriptive.has(LinkProperties.INTRA_LINKING)
&& !descriptive.has(LinkProperties.INTRA_LINKING_ONLY),
"Intra-only controls must not constrain Descriptive Books");
helper.assertTrue(descriptive.has(LinkProperties.DISARM)
&& descriptive.has(LinkProperties.RELATIVE_LINK)
&& descriptive.has(LinkProperties.DISABLE_PLATFORM),
"Shared link options must remain active on Descriptive Books");
helper.succeed();
}
@GameTest(template = "empty")
public static void linkSoundPriorityMatchesLegacy(GameTestHelper helper) {
helper.assertTrue(LinkVisualEffects.sound(LinkProperties.NONE, false) == ModSounds.LINK.get(),
"Ordinary book travel uses the Legacy link sound");
helper.assertTrue(LinkVisualEffects.sound(new LinkProperties(LinkProperties.INTRA_LINKING), false)
== ModSounds.LINK_INTRA.get(),
"Intra Linking uses its Legacy sound");
helper.assertTrue(LinkVisualEffects.sound(LinkProperties.NONE, true) == ModSounds.LINK_PORTAL.get(),
"Portal travel uses its Legacy sound");
helper.assertTrue(LinkVisualEffects.sound(new LinkProperties(LinkProperties.DISARM), true)
== ModSounds.LINK_DISARM.get(),
"Legacy Disarm takes precedence over the portal sound");
helper.succeed();
}
}
@@ -0,0 +1,23 @@
package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.world.item.ItemStack;
/** Legacy's compact page collection, using the existing Notebook page editor. */
public final class CollationFolderItem extends NotebookItem {
public static final int MAX_PAGES = 27;
public CollationFolderItem(Properties properties) {
super(properties);
}
public static boolean isCollection(ItemStack stack) {
return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.COLLATION_FOLDER.get());
}
public static int capacity(ItemStack stack) {
return stack.is(ModItems.COLLATION_FOLDER.get()) ? MAX_PAGES
: stack.is(ModItems.NOTEBOOK.get()) ? NotebookContents.MAX_PAGES : 0;
}
}
@@ -1,26 +1,28 @@
package net.dnorberg.rivencraft.item; package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import java.util.List; import java.util.List;
import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeVisitSavedData; import net.dnorberg.rivencraft.book.AgeVisitSavedData;
import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkProperties; import net.dnorberg.rivencraft.book.LinkProperties;
import net.dnorberg.rivencraft.book.LinkingService; import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.book.LinkVisualEffects;
import net.dnorberg.rivencraft.book.AgeDimensionService; import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.config.RivencraftConfig; import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModSounds;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
import net.minecraft.core.component.DataComponents; import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.MutableComponent;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundSource;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.Item; import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.phys.Vec3;
/** /**
* A book that will eventually describe and link to a generated Age. * A book that will eventually describe and link to a generated Age.
@@ -40,14 +42,18 @@ public final class DescriptiveBookItem extends Item {
} }
public static boolean useWrittenBook(ServerPlayer player, ItemStack stack) { public static boolean useWrittenBook(ServerPlayer player, ItemStack stack) {
return useWrittenBook(player, stack, false); return useWrittenBook(player, stack, false, null);
}
public static boolean useHandheldBook(ServerPlayer player, InteractionHand hand) {
return useWrittenBook(player, player.getItemInHand(hand), false, hand);
} }
public static boolean usePortalBook(ServerPlayer player, ItemStack stack) { public static boolean usePortalBook(ServerPlayer player, ItemStack stack) {
return useWrittenBook(player, stack, true); return useWrittenBook(player, stack, true, null);
} }
private static boolean useWrittenBook(ServerPlayer player, ItemStack stack, boolean portal) { private static boolean useWrittenBook(ServerPlayer player, ItemStack stack, boolean portal, InteractionHand hand) {
AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get()); AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get());
if (age == null) { if (age == null) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unwritten"), true); player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unwritten"), true);
@@ -55,19 +61,21 @@ public final class DescriptiveBookItem extends Item {
} }
LinkTarget returnTarget = LinkTarget.from(player); LinkTarget returnTarget = LinkTarget.from(player);
LinkTarget ageTarget = AgeDimensionService.resolve(player, age).orElse(null); LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
.forDescriptiveBook();
LinkTarget ageTarget = AgeDimensionService.resolve(player, age, net.dnorberg.rivencraft.writing.OrderedWriting.EMPTY,
!properties.has(LinkProperties.DISABLE_PLATFORM)).orElse(null);
if (ageTarget == null) { if (ageTarget == null) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true); player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
return false; return false;
} }
ServerLevel sourceLevel = player.serverLevel(); ServerLevel sourceLevel = player.serverLevel();
var sourcePosition = player.blockPosition(); Vec3 sourcePosition = player.position();
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
.forDescriptiveBook();
LinkingService.LinkResult result = portal LinkingService.LinkResult result = portal
? LinkingService.travelDescriptivePortal(player, ageTarget, properties) ? LinkingService.travelDescriptivePortal(player, ageTarget, properties)
: LinkingService.travelWithFooting(player, ageTarget, properties); : hand == null ? LinkingService.travelWithFooting(player, ageTarget, properties)
: LinkingService.travelHandheld(player, hand, stack, ageTarget, properties, true);
if (result != LinkingService.LinkResult.SUCCESS) { if (result != LinkingService.LinkResult.SUCCESS) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unsafe"), true); player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unsafe"), true);
return false; return false;
@@ -85,10 +93,8 @@ public final class DescriptiveBookItem extends Item {
player.drop(returnBook, false); player.drop(returnBook, false);
} }
} }
sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS, LinkVisualEffects.play(sourceLevel, sourcePosition, player, properties, portal);
0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F); ProgressionAdvancements.awardAgeTravel(player);
player.level().playSound(null, player.blockPosition(), (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS,
0.8F, player.level().random.nextFloat() * 0.2F + 0.9F);
player.displayClientMessage(Component.translatable( player.displayClientMessage(Component.translatable(
createReturnLink ? "message.rivencraft.descriptive_book.linked_first" : "message.rivencraft.descriptive_book.linked", createReturnLink ? "message.rivencraft.descriptive_book.linked_first" : "message.rivencraft.descriptive_book.linked",
age.name() age.name()
@@ -1,19 +1,19 @@
package net.dnorberg.rivencraft.item; package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import java.util.List; import java.util.List;
import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData; import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.LinkingService; import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.book.LinkVisualEffects;
import net.dnorberg.rivencraft.book.LinkProperties; import net.dnorberg.rivencraft.book.LinkProperties;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModSounds;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResultHolder; import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
@@ -21,6 +21,7 @@ import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level; import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
public final class LinkingBookItem extends Item { public final class LinkingBookItem extends Item {
public LinkingBookItem(Properties properties) { public LinkingBookItem(Properties properties) {
@@ -42,35 +43,35 @@ public final class LinkingBookItem extends Item {
} }
public static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack) { public static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack) {
return useBoundBook(player, stack, false); return useBoundBook(player, stack, false, null);
}
public static InteractionResultHolder<ItemStack> useHandheldBook(ServerPlayer player, InteractionHand hand) {
return useBoundBook(player, player.getItemInHand(hand), false, hand);
} }
public static InteractionResultHolder<ItemStack> usePortalBook(ServerPlayer player, ItemStack stack) { public static InteractionResultHolder<ItemStack> usePortalBook(ServerPlayer player, ItemStack stack) {
return useBoundBook(player, stack, true); return useBoundBook(player, stack, true, null);
} }
private static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack, boolean portal) { private static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack, boolean portal,
InteractionHand hand) {
LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get()); LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get());
if (target == null) { if (target == null) {
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.unbound"), true); player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.unbound"), true);
return InteractionResultHolder.fail(stack); return InteractionResultHolder.fail(stack);
} }
Level sourceLevel = player.level(); ServerLevel sourceLevel = player.serverLevel();
BlockPos sourcePosition = player.blockPosition(); Vec3 sourcePosition = player.position();
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE); LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
LinkingService.LinkResult result = LinkingService.travel(player, target, properties); LinkingService.LinkResult result = portal
? LinkingService.travelPortal(player, target, properties)
: hand == null ? LinkingService.travel(player, target, properties)
: LinkingService.travelHandheld(player, hand, stack, target, properties, false);
if (result == LinkingService.LinkResult.SUCCESS) { if (result == LinkingService.LinkResult.SUCCESS) {
sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS, LinkVisualEffects.play(sourceLevel, sourcePosition, player, properties, portal);
0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F); ProgressionAdvancements.awardAgeTravel(player);
player.level().playSound(
null,
player.blockPosition(),
(portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(),
SoundSource.PLAYERS,
0.8F,
player.level().random.nextFloat() * 0.2F + 0.9F
);
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.linked"), true); player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.linked"), true);
return InteractionResultHolder.success(stack); return InteractionResultHolder.success(stack);
} }
@@ -78,6 +79,7 @@ public final class LinkingBookItem extends Item {
String errorKey = switch (result) { String errorKey = switch (result) {
case MISSING_DIMENSION -> "message.rivencraft.linking_book.missing_dimension"; case MISSING_DIMENSION -> "message.rivencraft.linking_book.missing_dimension";
case INTRA_LINKING_REQUIRED -> "message.rivencraft.linking_book.intra_required"; case INTRA_LINKING_REQUIRED -> "message.rivencraft.linking_book.intra_required";
case INTRA_ONLY -> "message.rivencraft.linking_book.intra_only";
default -> "message.rivencraft.linking_book.unsafe"; default -> "message.rivencraft.linking_book.unsafe";
}; };
player.displayClientMessage(Component.translatable(errorKey), true); player.displayClientMessage(Component.translatable(errorKey), true);
@@ -16,7 +16,7 @@ import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level; import net.minecraft.world.level.Level;
public final class NotebookItem extends Item { public class NotebookItem extends Item {
public NotebookItem(Properties properties) { public NotebookItem(Properties properties) {
super(properties); super(properties);
} }
@@ -39,7 +39,9 @@ public final class NotebookItem extends Item {
NotebookContents contents = stack.getOrDefault( NotebookContents contents = stack.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY); ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
tooltip.add(Component.translatable("item.rivencraft.notebook.pages", tooltip.add(Component.translatable("item.rivencraft.notebook.pages",
contents.pageCount(), NotebookContents.MAX_PAGES).withStyle(ChatFormatting.GRAY)); contents.pageCount(), stack.is(net.dnorberg.rivencraft.registry.ModItems.COLLATION_FOLDER.get())
? CollationFolderItem.MAX_PAGES : NotebookContents.MAX_PAGES)
.withStyle(ChatFormatting.GRAY));
if (contents.unresolvedCount() > 0) { if (contents.unresolvedCount() > 0) {
tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved", tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved",
contents.unresolvedCount()).withStyle(ChatFormatting.RED)); contents.unresolvedCount()).withStyle(ChatFormatting.RED));
@@ -0,0 +1,62 @@
package net.dnorberg.rivencraft.item;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
/** Opens into a 16-page Collation Folder, as Legacy's booster item did. */
public final class SealedNotebookItem extends Item {
private static final int PAGE_COUNT = 16;
public SealedNotebookItem(Properties properties) {
super(properties);
}
@Override
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
ItemStack sealed = player.getItemInHand(hand);
if (level.isClientSide) return InteractionResultHolder.sidedSuccess(sealed, true);
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResultHolder.pass(sealed);
ItemStack folder = open(serverPlayer);
if (folder.isEmpty()) return InteractionResultHolder.fail(sealed);
if (!player.hasInfiniteMaterials()) sealed.shrink(1);
if (sealed.isEmpty()) {
player.setItemInHand(hand, folder);
} else if (!player.getInventory().add(folder)) {
player.drop(folder, false);
}
return InteractionResultHolder.success(player.getItemInHand(hand));
}
static ItemStack open(ServerPlayer player) {
// Current symbol definitions have no Legacy rank. Draw from the authored
// symbol pool, excluding virtual block/biome pages and corrupted pages.
List<ResourceLocation> candidates = SymbolCatalog.symbols().entrySet().stream()
.filter(entry -> entry.getKey().getNamespace().equals("rivencraft")
&& !entry.getKey().getPath().startsWith("block/")
&& !entry.getKey().getPath().startsWith("biome/")
&& !entry.getValue().category().getPath().equals("corrupted"))
.map(java.util.Map.Entry::getKey).sorted().toList();
if (candidates.isEmpty()) return ItemStack.EMPTY;
List<ItemStack> pages = new ArrayList<>(PAGE_COUNT);
for (int i = 0; i < PAGE_COUNT; i++) {
ResourceLocation id = candidates.get(player.getRandom().nextInt(candidates.size()));
pages.add(PageItem.createSymbolPage(new SymbolReference(id)));
}
ItemStack folder = new ItemStack(ModItems.COLLATION_FOLDER.get());
folder.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
return folder;
}
}
@@ -1,14 +1,17 @@
package net.dnorberg.rivencraft.menu; package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import java.util.Optional; import java.util.Optional;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity; import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.registry.ModMenus; import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.WritingGrammar; import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Container; import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer; import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Inventory;
@@ -170,7 +173,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
private boolean clickStrip(int index) { private boolean clickStrip(int index) {
ItemStack carried = getCarried(); ItemStack carried = getCarried();
if (carried.is(ModItems.NOTEBOOK.get())) { if (CollationFolderItem.isCollection(carried)) {
ItemStack emptied = binder.importNotebookFromStack(carried, Math.min(index == 0 ? 0 : index - 1, ItemStack emptied = binder.importNotebookFromStack(carried, Math.min(index == 0 ? 0 : index - 1,
binder.nextPageIndex())); binder.nextPageIndex()));
if (emptied.isEmpty()) return false; if (emptied.isEmpty()) return false;
@@ -210,7 +213,9 @@ public final class BookBinderMenu extends AbstractContainerMenu {
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) { if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) {
return ItemStack.EMPTY; return ItemStack.EMPTY;
} }
binder.consumeAssembly(); if (!binder.consumeAssembly()) return ItemStack.EMPTY;
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.BIND_AGE);
refreshResult(); refreshResult();
return book; return book;
} }
@@ -223,7 +228,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
moved = moveItemStackTo(source, PLAYER_START, slots.size(), true); moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
} else if (source.is(Items.LEATHER) || BookBinderBlockEntity.validCover(source)) { } else if (source.is(Items.LEATHER) || BookBinderBlockEntity.validCover(source)) {
moved = moveItemStackTo(source, BookBinderBlockEntity.COVER, BookBinderBlockEntity.COVER + 1, false); moved = moveItemStackTo(source, BookBinderBlockEntity.COVER, BookBinderBlockEntity.COVER + 1, false);
} else if (source.is(ModItems.NOTEBOOK.get())) { } else if (CollationFolderItem.isCollection(source)) {
ItemStack emptied = binder.importNotebookFromStack(source, binder.nextPageIndex()); ItemStack emptied = binder.importNotebookFromStack(source, binder.nextPageIndex());
if (emptied.isEmpty()) return ItemStack.EMPTY; if (emptied.isEmpty()) return ItemStack.EMPTY;
sourceSlot.setByPlayer(emptied); sourceSlot.setByPlayer(emptied);
@@ -284,7 +289,11 @@ public final class BookBinderMenu extends AbstractContainerMenu {
return clientSide ? hasItem() : stillValid(player) && binder.canAssemble(); return clientSide ? hasItem() : stillValid(player) && binder.canAssemble();
} }
@Override public void onTake(Player player, ItemStack stack) { @Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && binder.consumeAssembly()) refreshResult(); if (!clientSide && binder.consumeAssembly()) {
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.BIND_AGE);
refreshResult();
}
super.onTake(player, stack); super.onTake(player, stack);
} }
} }
@@ -1,10 +1,12 @@
package net.dnorberg.rivencraft.menu; package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus; import net.dnorberg.rivencraft.registry.ModMenus;
import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Container; import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer; import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Inventory;
@@ -109,6 +111,8 @@ public final class InkMixerMenu extends AbstractContainerMenu {
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
return ItemStack.EMPTY; return ItemStack.EMPTY;
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY; if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
refreshResult(); refreshResult();
return output; return output;
} }
@@ -148,7 +152,11 @@ public final class InkMixerMenu extends AbstractContainerMenu {
return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel(); return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel();
} }
@Override public void onTake(Player player, ItemStack stack) { @Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && mixer.consumeLinkPanelMaterials()) refreshResult(); if (!clientSide && mixer.consumeLinkPanelMaterials()) {
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
refreshResult();
}
super.onTake(player, stack); super.onTake(player, stack);
} }
} }
@@ -0,0 +1,200 @@
package net.dnorberg.rivencraft.menu;
import java.util.UUID;
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeDeletionService;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.LinkProperties;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
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.DataSlot;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
/** Server-owned book edits and a two-stage, OP-only Age retirement control. */
public final class LinkModifierMenu extends AbstractContainerMenu {
public static final int TOGGLE_DISARM = 1;
public static final int TOGGLE_MOMENTUM = 2;
public static final int TOGGLE_INTRA = 3;
public static final int DELETE_AGE = 4;
public static final int TOGGLE_INTRA_ONLY = 5;
public static final int TOGGLE_RELATIVE = 6;
public static final int TOGGLE_PLATFORM = 7;
private static final int PLAYER_START = 1;
private static final int MAX_TITLE_LENGTH = 32;
private final LinkModifierBlockEntity modifier;
private final Container bookSlot;
private final boolean clientSide;
private final Player owner;
private final int[] seedWords = new int[4];
private int confirmation;
private UUID pendingAge;
public LinkModifierMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof LinkModifierBlockEntity found ? found : null);
}
public LinkModifierMenu(int id, Inventory inventory, LinkModifierBlockEntity modifier) {
super(ModMenus.LINK_MODIFIER.get(), id);
this.modifier = modifier;
this.bookSlot = modifier == null ? new SimpleContainer(1) : modifier;
this.clientSide = inventory.player.level().isClientSide;
this.owner = inventory.player;
addDataSlot(new DataSlot() {
@Override public int get() { return confirmation; }
@Override public void set(int value) { confirmation = value; }
});
for (int index = 0; index < seedWords.length; index++) {
final int word = index;
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide ? seedWords[word] : (int) ((seed() >>> (word * 16)) & 0xffffL);
}
@Override public void set(int value) { seedWords[word] = value & 0xffff; }
});
}
addSlot(new Slot(bookSlot, 0, 80, 35) {
@Override public boolean mayPlace(ItemStack stack) { return bookSlot.canPlaceItem(0, stack); }
@Override public int getMaxStackSize() { return 1; }
@Override public void setChanged() {
super.setChanged();
pendingAge = null;
confirmation = 0;
}
});
for (int row = 0; row < 3; row++) for (int col = 0; col < 9; col++)
addSlot(new Slot(inventory, col + row * 9 + 9, 8 + col * 18, 84 + row * 18));
for (int col = 0; col < 9; col++)
addSlot(new Slot(inventory, col, 8 + col * 18, 142));
}
public ItemStack book() { return bookSlot.getItem(0); }
public boolean hasSeed() {
return book().is(ModItems.DESCRIPTIVE_BOOK.get())
&& book().has(ModDataComponents.AGE_REFERENCE.get());
}
public long seed() {
if (clientSide) {
long result = 0;
for (int index = 0; index < seedWords.length; index++)
result |= (seedWords[index] & 0xffffL) << (index * 16);
return result;
}
if (!hasSeed()) return 0;
AgeReference reference = book().get(ModDataComponents.AGE_REFERENCE.get());
return AgeRegistrySavedData.get(((ServerPlayer) owner).serverLevel()).find(reference.id())
.map(AgeDefinition::seed).orElse(0L);
}
public boolean setSeed(Player player, String text) {
if (clientSide || !stillValid(player) || !hasSeed() || text == null || text.length() > 21
|| !(player instanceof ServerPlayer serverPlayer)) return false;
long requested;
try { requested = Long.parseLong(text.strip()); }
catch (NumberFormatException exception) { return false; }
AgeReference reference = book().get(ModDataComponents.AGE_REFERENCE.get());
if (reference == null) return false;
boolean changed = AgeRegistrySavedData.get(serverPlayer.serverLevel())
.reseed(serverPlayer.getServer(), reference.id(), requested);
player.displayClientMessage(Component.translatable(changed
? "message.rivencraft.link_modifier.seed_changed"
: "message.rivencraft.link_modifier.seed_failed"), false);
if (changed) broadcastChanges();
return changed;
}
public boolean awaitingDeletionConfirmation() { return confirmation == 1; }
public boolean canDelete(Player player) {
return player.hasPermissions(2) && book().is(ModItems.DESCRIPTIVE_BOOK.get())
&& book().has(ModDataComponents.AGE_REFERENCE.get());
}
public boolean setTitle(Player player, String title) {
if (clientSide || !stillValid(player) || title == null || title.length() > MAX_TITLE_LENGTH
|| book().isEmpty()) return false;
String clean = title.strip();
if (clean.isEmpty()) book().remove(DataComponents.CUSTOM_NAME);
else book().set(DataComponents.CUSTOM_NAME, Component.literal(clean));
bookSlot.setChanged();
broadcastChanges();
return true;
}
@Override public boolean clickMenuButton(Player player, int id) {
if (clientSide || !stillValid(player) || book().isEmpty()) return false;
if (id == DELETE_AGE) return retireAge(player);
int flag = switch (id) {
case TOGGLE_DISARM -> LinkProperties.DISARM;
case TOGGLE_MOMENTUM -> LinkProperties.MAINTAIN_MOMENTUM;
case TOGGLE_INTRA -> LinkProperties.INTRA_LINKING;
case TOGGLE_INTRA_ONLY -> LinkProperties.INTRA_LINKING_ONLY;
case TOGGLE_RELATIVE -> LinkProperties.RELATIVE_LINK;
case TOGGLE_PLATFORM -> LinkProperties.DISABLE_PLATFORM;
default -> 0;
};
if (flag == 0 || ((flag == LinkProperties.INTRA_LINKING || flag == LinkProperties.INTRA_LINKING_ONLY)
&& book().is(ModItems.DESCRIPTIVE_BOOK.get()))) return false;
int flags = book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE).flags();
if (flag == LinkProperties.INTRA_LINKING_ONLY && (flags & LinkProperties.INTRA_LINKING_ONLY) == 0)
flags |= LinkProperties.INTRA_LINKING;
if (flag == LinkProperties.INTRA_LINKING && (flags & LinkProperties.INTRA_LINKING) != 0)
flags &= ~LinkProperties.INTRA_LINKING_ONLY;
book().set(ModDataComponents.LINK_PROPERTIES.get(), new LinkProperties(flags ^ flag));
bookSlot.setChanged();
pendingAge = null;
confirmation = 0;
broadcastChanges();
return true;
}
private boolean retireAge(Player player) {
if (!(player instanceof ServerPlayer serverPlayer) || !canDelete(player)) return false;
AgeReference reference = book().get(ModDataComponents.AGE_REFERENCE.get());
if (reference == null) return false;
AgeDefinition age = AgeRegistrySavedData.get(serverPlayer.serverLevel()).find(reference.id()).orElse(null);
if (age == null) return false;
if (confirmation != 1 || !reference.id().equals(pendingAge)) {
pendingAge = reference.id();
confirmation = 1;
broadcastChanges();
return true;
}
confirmation = 0;
pendingAge = null;
AgeDeletionService.Result result = AgeDeletionService.retire(serverPlayer.getServer(), age);
player.displayClientMessage(Component.translatable(
"message.rivencraft.link_modifier.delete." + result.name().toLowerCase()), false);
broadcastChanges();
return result == AgeDeletionService.Result.RETIRED;
}
@Override public boolean stillValid(Player player) {
return modifier != null && modifier.stillValid(player);
}
@Override public ItemStack quickMoveStack(Player player, int index) {
Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack source = slot.getItem();
ItemStack copy = source.copy();
if (index == 0) {
if (!moveItemStackTo(source, PLAYER_START, slots.size(), true)) return ItemStack.EMPTY;
} else if (!moveItemStackTo(source, 0, 1, false)) return ItemStack.EMPTY;
if (source.isEmpty()) slot.set(ItemStack.EMPTY);
else slot.setChanged();
return copy;
}
}
@@ -7,6 +7,7 @@ import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.NotebookContents; import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.minecraft.core.NonNullList; import net.minecraft.core.NonNullList;
import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container; import net.minecraft.world.Container;
@@ -28,6 +29,7 @@ public final class NotebookMenu extends AbstractContainerMenu {
private final InteractionHand hand; private final InteractionHand hand;
private final int openedHotbarSlot; private final int openedHotbarSlot;
private final NotebookContainer contents; private final NotebookContainer contents;
private final int pageCapacity;
public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) { public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) {
this(containerId, inventory, data.readBoolean() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND); this(containerId, inventory, data.readBoolean() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND);
@@ -38,11 +40,12 @@ public final class NotebookMenu extends AbstractContainerMenu {
this.playerInventory = inventory; this.playerInventory = inventory;
this.hand = hand; this.hand = hand;
this.openedHotbarSlot = inventory.selected; this.openedHotbarSlot = inventory.selected;
this.pageCapacity = CollationFolderItem.capacity(inventory.player.getItemInHand(hand));
this.contents = new NotebookContainer(inventory, hand); this.contents = new NotebookContainer(inventory, hand);
for (int page = 0; page < NOTEBOOK_SLOTS; page++) { for (int page = 0; page < NOTEBOOK_SLOTS; page++) {
// The client draws these pages on a filtered, scrollable surface instead. // The client draws these pages on a filtered, scrollable surface instead.
addSlot(new PageSlot(contents, page, -1000, -1000)); addSlot(new PageSlot(contents, page, -1000, -1000, page < pageCapacity));
} }
for (int row = 0; row < 3; row++) { for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) { for (int column = 0; column < 9; column++) {
@@ -61,11 +64,13 @@ public final class NotebookMenu extends AbstractContainerMenu {
: new Slot(playerInventory, inventoryIndex, x, y); : new Slot(playerInventory, inventoryIndex, x, y);
} }
public int pageCapacity() { return pageCapacity; }
@Override @Override
public boolean stillValid(Player player) { public boolean stillValid(Player player) {
return player.isAlive() return player.isAlive()
&& (hand == InteractionHand.OFF_HAND || player.getInventory().selected == openedHotbarSlot) && (hand == InteractionHand.OFF_HAND || player.getInventory().selected == openedHotbarSlot)
&& player.getItemInHand(hand).is(ModItems.NOTEBOOK.get()); && CollationFolderItem.isCollection(player.getItemInHand(hand));
} }
@Override @Override
@@ -108,7 +113,7 @@ public final class NotebookMenu extends AbstractContainerMenu {
} else { } else {
return false; return false;
} }
if (source < 0 || source >= NOTEBOOK_SLOTS || target < 0 || target >= NOTEBOOK_SLOTS if (source < 0 || source >= pageCapacity || target < 0 || target >= pageCapacity
|| contents.getItem(source).isEmpty()) { || contents.getItem(source).isEmpty()) {
return false; return false;
} }
@@ -132,14 +137,18 @@ public final class NotebookMenu extends AbstractContainerMenu {
} }
private static final class PageSlot extends Slot { private static final class PageSlot extends Slot {
private PageSlot(Container container, int slot, int x, int y) { private final boolean enabled;
private PageSlot(Container container, int slot, int x, int y, boolean enabled) {
super(container, slot, x, y); super(container, slot, x, y);
this.enabled = enabled;
} }
@Override public boolean mayPlace(ItemStack stack) { @Override public boolean mayPlace(ItemStack stack) {
return stack.is(ModItems.PAGE.get()) return enabled && stack.is(ModItems.PAGE.get())
&& PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL; && PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
} }
@Override public boolean mayPickup(Player player) { return enabled; }
@Override public int getMaxStackSize() { return 1; } @Override public int getMaxStackSize() { return 1; }
} }
@@ -184,11 +193,12 @@ public final class NotebookMenu extends AbstractContainerMenu {
public void setChanged() { public void setChanged() {
super.setChanged(); super.setChanged();
ItemStack notebook = inventory.player.getItemInHand(hand); ItemStack notebook = inventory.player.getItemInHand(hand);
if (loading || !notebook.is(ModItems.NOTEBOOK.get())) { if (loading || !CollationFolderItem.isCollection(notebook)) {
return; return;
} }
NonNullList<ItemStack> saved = NonNullList.withSize(NOTEBOOK_SLOTS, ItemStack.EMPTY); int capacity = CollationFolderItem.capacity(notebook);
for (int index = 0; index < NOTEBOOK_SLOTS; index++) { NonNullList<ItemStack> saved = NonNullList.withSize(capacity, ItemStack.EMPTY);
for (int index = 0; index < capacity; index++) {
ItemStack page = getItem(index); ItemStack page = getItem(index);
saved.set(index, page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1)); saved.set(index, page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1));
} }
@@ -1,11 +1,13 @@
package net.dnorberg.rivencraft.menu; package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus; import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.NotebookContents; import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog; import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata; import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
@@ -106,7 +108,7 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
public int pageCapacity(int collectionIndex) { public int pageCapacity(int collectionIndex) {
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS) return 0; if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS) return 0;
ItemStack collection = desk.getItem(collectionIndex); ItemStack collection = desk.getItem(collectionIndex);
return collection.is(ModItems.NOTEBOOK.get()) ? NotebookContents.MAX_PAGES return CollationFolderItem.isCollection(collection) ? CollationFolderItem.capacity(collection)
: collection.is(ModItems.PAGE.get()) ? 1 : 0; : collection.is(ModItems.PAGE.get()) ? 1 : 0;
} }
public List<SymbolDisplayMetadata> catalog() { return catalog; } public List<SymbolDisplayMetadata> catalog() { return catalog; }
@@ -182,7 +184,8 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
ItemStack carried = getCarried(); ItemStack carried = getCarried();
if (clientSide || !storablePage(carried)) return false; if (clientSide || !storablePage(carried)) return false;
ItemStack collection = desk.getItem(collectionIndex); ItemStack collection = desk.getItem(collectionIndex);
if (collection.is(ModItems.NOTEBOOK.get())) { if (CollationFolderItem.isCollection(collection)) {
if (pageIndex < 0 || pageIndex >= CollationFolderItem.capacity(collection)) return false;
var pages = new java.util.ArrayList<>(collection.getOrDefault( var pages = new java.util.ArrayList<>(collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()); ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
while (pages.size() <= pageIndex) pages.add(ItemStack.EMPTY); while (pages.size() <= pageIndex) pages.add(ItemStack.EMPTY);
@@ -206,8 +209,10 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
if (!getCarried().isEmpty() || sourceCollection == targetCollection && sourcePage == targetPage) return false; if (!getCarried().isEmpty() || sourceCollection == targetCollection && sourcePage == targetPage) return false;
ItemStack sourceBook = desk.getItem(sourceCollection); ItemStack sourceBook = desk.getItem(sourceCollection);
ItemStack targetBook = desk.getItem(targetCollection); ItemStack targetBook = desk.getItem(targetCollection);
boolean sourceNotebook = sourceBook.is(ModItems.NOTEBOOK.get()); boolean sourceNotebook = CollationFolderItem.isCollection(sourceBook);
boolean targetNotebook = targetBook.is(ModItems.NOTEBOOK.get()); boolean targetNotebook = CollationFolderItem.isCollection(targetBook);
if (sourceNotebook && sourcePage >= CollationFolderItem.capacity(sourceBook)
|| targetNotebook && targetPage >= CollationFolderItem.capacity(targetBook)) return false;
if ((!sourceNotebook && (!sourceBook.is(ModItems.PAGE.get()) || sourcePage != 0)) if ((!sourceNotebook && (!sourceBook.is(ModItems.PAGE.get()) || sourcePage != 0))
|| (!targetNotebook && (!targetBook.is(ModItems.PAGE.get()) || targetPage != 0))) return false; || (!targetNotebook && (!targetBook.is(ModItems.PAGE.get()) || targetPage != 0))) return false;
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault( var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
@@ -246,7 +251,8 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
private boolean extractPage(int sourceCollection, int sourcePage, int targetSlot) { private boolean extractPage(int sourceCollection, int sourcePage, int targetSlot) {
if (!getCarried().isEmpty() || targetSlot < -1 || targetSlot >= slots.size()) return false; if (!getCarried().isEmpty() || targetSlot < -1 || targetSlot >= slots.size()) return false;
ItemStack sourceBook = desk.getItem(sourceCollection); ItemStack sourceBook = desk.getItem(sourceCollection);
boolean sourceNotebook = sourceBook.is(ModItems.NOTEBOOK.get()); boolean sourceNotebook = CollationFolderItem.isCollection(sourceBook);
if (sourceNotebook && sourcePage >= CollationFolderItem.capacity(sourceBook)) return false;
if (!sourceNotebook && (!sourceBook.is(ModItems.PAGE.get()) || sourcePage != 0)) return false; if (!sourceNotebook && (!sourceBook.is(ModItems.PAGE.get()) || sourcePage != 0)) return false;
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault( var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null; ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
@@ -262,12 +268,12 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
Slot target = slots.get(targetSlot); Slot target = slots.get(targetSlot);
if (!target.isActive() || !target.mayPlace(moving)) return false; if (!target.isActive() || !target.mayPlace(moving)) return false;
ItemStack current = target.getItem(); ItemStack current = target.getItem();
if (targetSlot < WritingDeskBlockEntity.COLLECTIONS && current.is(ModItems.NOTEBOOK.get())) { if (targetSlot < WritingDeskBlockEntity.COLLECTIONS && CollationFolderItem.isCollection(current)) {
var targetPages = targetSlot == sourceCollection ? sourcePages var targetPages = targetSlot == sourceCollection ? sourcePages
: new java.util.ArrayList<>(current.getOrDefault( : new java.util.ArrayList<>(current.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()); ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
int position = -1; int position = -1;
for (int i = 0; i < NotebookContents.MAX_PAGES; i++) { for (int i = 0; i < CollationFolderItem.capacity(current); i++) {
if (i >= targetPages.size() || targetPages.get(i).isEmpty()) { if (i >= targetPages.size() || targetPages.get(i).isEmpty()) {
position = i; position = i;
break; break;
@@ -317,7 +323,9 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
if (!moveItemStackTo(movingCopy, PLAYER_START, slots.size(), true) || !movingCopy.isEmpty()) { if (!moveItemStackTo(movingCopy, PLAYER_START, slots.size(), true) || !movingCopy.isEmpty()) {
return ItemStack.EMPTY; return ItemStack.EMPTY;
} }
blockEntity.consumeCopyMaterials(); if (!blockEntity.consumeCopyMaterials()) return ItemStack.EMPTY;
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.COPY_PAGE);
refreshOutput(); refreshOutput();
return copy; return copy;
} }
@@ -339,7 +347,7 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
private boolean moveIntoDesk(ItemStack moving) { private boolean moveIntoDesk(ItemStack moving) {
if (moving.is(ModItems.INK_VIAL.get())) return moveItemStackTo(moving, if (moving.is(ModItems.INK_VIAL.get())) return moveItemStackTo(moving,
WritingDeskBlockEntity.INK, WritingDeskBlockEntity.INK + 1, false); WritingDeskBlockEntity.INK, WritingDeskBlockEntity.INK + 1, false);
if (moving.is(ModItems.NOTEBOOK.get())) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false); if (CollationFolderItem.isCollection(moving)) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
if (moving.is(Items.PAPER)) if (moving.is(Items.PAPER))
return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER, return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER,
WritingDeskBlockEntity.PAPER + 1, false); WritingDeskBlockEntity.PAPER + 1, false);
@@ -371,7 +379,8 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
|| pageIndex < 0 || pageIndex >= NotebookContents.MAX_PAGES) return ItemStack.EMPTY; || pageIndex < 0 || pageIndex >= NotebookContents.MAX_PAGES) return ItemStack.EMPTY;
ItemStack collection = desk.getItem(collectionIndex); ItemStack collection = desk.getItem(collectionIndex);
if (collection.is(ModItems.PAGE.get())) return pageIndex == 0 ? collection : ItemStack.EMPTY; if (collection.is(ModItems.PAGE.get())) return pageIndex == 0 ? collection : ItemStack.EMPTY;
if (collection.is(ModItems.NOTEBOOK.get())) { if (CollationFolderItem.isCollection(collection)) {
if (pageIndex >= CollationFolderItem.capacity(collection)) return ItemStack.EMPTY;
List<ItemStack> pages = collection.getOrDefault( List<ItemStack> pages = collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages(); ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
return pageIndex < pages.size() ? pages.get(pageIndex) : ItemStack.EMPTY; return pageIndex < pages.size() ? pages.get(pageIndex) : ItemStack.EMPTY;
@@ -434,7 +443,11 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
return clientSide ? hasItem() : WritingDeskMenu.this.stillValid(player) && canCopy(); return clientSide ? hasItem() : WritingDeskMenu.this.stillValid(player) && canCopy();
} }
@Override public void onTake(Player player, ItemStack stack) { @Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && blockEntity.consumeCopyMaterials()) refreshOutput(); if (!clientSide && blockEntity.consumeCopyMaterials()) {
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.COPY_PAGE);
refreshOutput();
}
super.onTake(player, stack); super.onTake(player, stack);
} }
} }
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.mixin;
import dev.galacticraft.dynamicdimensions.impl.DynamicDimensionRegistryImpl;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.storage.WorldData;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.ModifyVariable;
/** Give Dynamic Dimensions the saved Age seed before it constructs the level and its chunk RandomState. */
@Mixin(value = DynamicDimensionRegistryImpl.class, remap = false)
public abstract class AgeDimensionSeedMixin {
@ModifyVariable(method = "createDynamicLevel(Lnet/minecraft/resources/ResourceKey;Lnet/minecraft/world/level/storage/WorldData;Lnet/minecraft/world/level/dimension/LevelStem;Lnet/minecraft/server/level/ServerLevel;)Lnet/minecraft/server/level/ServerLevel;",
at = @At("STORE"), ordinal = 0, remap = false)
private long rivencraft$useSavedAgeSeed(long defaultSeed, ResourceKey<Level> dimension,
WorldData worldData, LevelStem stem, ServerLevel overworld) {
return AgeRegistrySavedData.get(overworld).findByDimension(dimension.location())
.map(age -> age.seed()).orElse(defaultSeed);
}
}
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.network; package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkProperties;
import net.dnorberg.rivencraft.book.LinkingService; import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData; import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
@@ -18,6 +19,7 @@ import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
import net.dnorberg.rivencraft.item.DescriptiveBookItem; import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem; import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.menu.BookBinderMenu; import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.OrderedWriting;
@@ -59,7 +61,7 @@ public final class ModNetworking {
} }
public static void register(RegisterPayloadHandlersEvent event) { public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("15"); PayloadRegistrar registrar = event.registrar("17");
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC, registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
ModNetworking::requestDescriptiveBookScreen); ModNetworking::requestDescriptiveBookScreen);
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC, registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
@@ -85,6 +87,10 @@ public final class ModNetworking {
ClientWritingDeskCatalog::handle); ClientWritingDeskCatalog::handle);
registrar.playToServer(SetBookBinderTitlePayload.TYPE, SetBookBinderTitlePayload.STREAM_CODEC, registrar.playToServer(SetBookBinderTitlePayload.TYPE, SetBookBinderTitlePayload.STREAM_CODEC,
ModNetworking::setBookBinderTitle); ModNetworking::setBookBinderTitle);
registrar.playToServer(SetLinkModifierTitlePayload.TYPE, SetLinkModifierTitlePayload.STREAM_CODEC,
ModNetworking::setLinkModifierTitle);
registrar.playToServer(SetLinkModifierSeedPayload.TYPE, SetLinkModifierSeedPayload.STREAM_CODEC,
ModNetworking::setLinkModifierSeed);
} }
private static void setBookBinderTitle(SetBookBinderTitlePayload payload, IPayloadContext context) { private static void setBookBinderTitle(SetBookBinderTitlePayload payload, IPayloadContext context) {
@@ -95,6 +101,21 @@ public final class ModNetworking {
} }
} }
private static void setLinkModifierTitle(SetLinkModifierTitlePayload payload, IPayloadContext context) {
if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof LinkModifierMenu menu
&& menu.containerId == payload.containerId()) {
menu.setTitle(player, payload.title());
}
}
private static void setLinkModifierSeed(SetLinkModifierSeedPayload payload, IPayloadContext context) {
if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof LinkModifierMenu menu
&& menu.containerId == payload.containerId())
menu.setSeed(player, payload.seed());
}
private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) { private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) { if (!(context.player() instanceof ServerPlayer player)) {
return; return;
@@ -163,7 +184,9 @@ public final class ModNetworking {
try { try {
AgeReference reference = new AgeReference(UUID.randomUUID(), title); AgeReference reference = new AgeReference(UUID.randomUUID(), title);
if (AgeDimensionService.resolve(player, reference, access.stack() if (AgeDimensionService.resolve(player, reference, access.stack()
.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY)).isEmpty()) { .getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY),
!access.stack().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
.has(LinkProperties.DISABLE_PLATFORM)).isEmpty()) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true); player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
return; return;
} }
@@ -211,7 +234,7 @@ public final class ModNetworking {
ItemStack stack = player.getItemInHand(hand(payload.offhand())); ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (stack.is(ModItems.LINKING_BOOK.get()) && stack.has(ModDataComponents.LINK_TARGET.get())) { if (stack.is(ModItems.LINKING_BOOK.get()) && stack.has(ModDataComponents.LINK_TARGET.get())) {
LinkingBookItem.useBoundBook(player, stack); LinkingBookItem.useHandheldBook(player, hand(payload.offhand()));
} }
} }
@@ -245,7 +268,8 @@ public final class ModNetworking {
} }
if (stack.is(ModItems.DESCRIPTIVE_BOOK.get())) { if (stack.is(ModItems.DESCRIPTIVE_BOOK.get())) {
DescriptiveBookItem.useWrittenBook(player, stack); if (payload.fromStand()) DescriptiveBookItem.useWrittenBook(player, stack);
else DescriptiveBookItem.useHandheldBook(player, hand(payload.offhand()));
} }
} }
@@ -268,7 +292,9 @@ public final class ModNetworking {
LinkTarget target; LinkTarget target;
if (payload.descriptiveBook() && stack.is(ModItems.DESCRIPTIVE_BOOK.get())) { if (payload.descriptiveBook() && stack.is(ModItems.DESCRIPTIVE_BOOK.get())) {
AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get()); AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get());
target = age == null ? null : AgeDimensionService.resolve(player, age).orElse(null); target = age == null ? null : AgeDimensionService.resolve(player, age, OrderedWriting.EMPTY,
!stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
.has(LinkProperties.DISABLE_PLATFORM)).orElse(null);
} else if (!payload.descriptiveBook() && stack.is(ModItems.LINKING_BOOK.get())) { } else if (!payload.descriptiveBook() && stack.is(ModItems.LINKING_BOOK.get())) {
target = stack.get(ModDataComponents.LINK_TARGET.get()); target = stack.get(ModDataComponents.LINK_TARGET.get());
} else { } else {
@@ -0,0 +1,19 @@
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.ResourceLocation;
public record SetLinkModifierSeedPayload(int containerId, String seed) implements CustomPacketPayload {
public static final Type<SetLinkModifierSeedPayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_link_modifier_seed"));
public static final StreamCodec<RegistryFriendlyByteBuf, SetLinkModifierSeedPayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetLinkModifierSeedPayload::containerId,
ByteBufCodecs.STRING_UTF8, SetLinkModifierSeedPayload::seed,
SetLinkModifierSeedPayload::new);
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -0,0 +1,19 @@
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.ResourceLocation;
public record SetLinkModifierTitlePayload(int containerId, String title) implements CustomPacketPayload {
public static final Type<SetLinkModifierTitlePayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_link_modifier_title"));
public static final StreamCodec<RegistryFriendlyByteBuf, SetLinkModifierTitlePayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetLinkModifierTitlePayload::containerId,
ByteBufCodecs.STRING_UTF8, SetLinkModifierTitlePayload::title,
SetLinkModifierTitlePayload::new);
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -14,8 +14,12 @@ import net.dnorberg.rivencraft.registry.ModBlocks;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction; import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.BlockSnapshot;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Bounded, loaded-chunk-only adaptation of Legacy's crystal network flood. */ /** Bounded, loaded-chunk-only adaptation of Legacy's crystal network flood. */
public final class CrystalPortalNetwork { public final class CrystalPortalNetwork {
@@ -32,14 +36,22 @@ public final class CrystalPortalNetwork {
private CrystalPortalNetwork() {} private CrystalPortalNetwork() {}
public record Plan(List<BlockPos> portalCells) { public record Plan(List<BlockPos> portalCells, boolean waitingForChunks) {
public Plan(List<BlockPos> portalCells) { this(portalCells, false); }
public boolean valid() { return !portalCells.isEmpty(); } public boolean valid() { return !portalCells.isEmpty(); }
} }
public enum CellStatus { VALID, INVALID, WAITING_FOR_CHUNKS }
public static Plan plan(ServerLevel level, BlockPos receptacle) { public static Plan plan(ServerLevel level, BlockPos receptacle) {
return plan(level, receptacle, false);
}
private static Plan plan(ServerLevel level, BlockPos receptacle, boolean allowUnownedCells) {
BlockState holder = level.getBlockState(receptacle); BlockState holder = level.getBlockState(receptacle);
if (!holder.is(ModBlocks.BOOK_RECEPTACLE.get())) return new Plan(List.of()); if (!holder.is(ModBlocks.BOOK_RECEPTACLE.get())) return new Plan(List.of());
BlockPos base = receptacle.relative(holder.getValue(BookReceptacleBlock.FACING).getOpposite()); BlockPos base = receptacle.relative(holder.getValue(BookReceptacleBlock.FACING).getOpposite());
if (!level.hasChunkAt(base)) return new Plan(List.of(), true);
if (!level.getBlockState(base).is(ModBlocks.CRYSTAL_BLOCK.get())) return new Plan(List.of()); if (!level.getBlockState(base).is(ModBlocks.CRYSTAL_BLOCK.get())) return new Plan(List.of());
Set<BlockPos> crystals = new HashSet<>(); Set<BlockPos> crystals = new HashSet<>();
@@ -48,7 +60,7 @@ public final class CrystalPortalNetwork {
while (!pendingCrystals.isEmpty()) { while (!pendingCrystals.isEmpty()) {
BlockPos pos = pendingCrystals.removeFirst(); BlockPos pos = pendingCrystals.removeFirst();
if (!within(base, pos) || !level.isInWorldBounds(pos)) continue; if (!within(base, pos) || !level.isInWorldBounds(pos)) continue;
if (!level.hasChunkAt(pos)) return new Plan(List.of()); if (!level.hasChunkAt(pos)) return new Plan(List.of(), true);
if (!level.getBlockState(pos).is(ModBlocks.CRYSTAL_BLOCK.get()) || !crystals.add(pos)) continue; if (!level.getBlockState(pos).is(ModBlocks.CRYSTAL_BLOCK.get()) || !crystals.add(pos)) continue;
if (atBoundary(base, pos)) return new Plan(List.of()); if (atBoundary(base, pos)) return new Plan(List.of());
if (crystals.size() > MAX_CRYSTALS) return new Plan(List.of()); if (crystals.size() > MAX_CRYSTALS) return new Plan(List.of());
@@ -62,8 +74,9 @@ public final class CrystalPortalNetwork {
while (!candidates.isEmpty()) { while (!candidates.isEmpty()) {
BlockPos pos = candidates.removeFirst(); BlockPos pos = candidates.removeFirst();
queued.remove(pos); queued.remove(pos);
if (!level.isInWorldBounds(pos) || !level.hasChunkAt(pos)) return new Plan(List.of()); if (!level.isInWorldBounds(pos)) return new Plan(List.of());
if (portals.contains(pos) || !mayFill(level, pos, receptacle)) continue; if (!level.hasChunkAt(pos)) return new Plan(List.of(), true);
if (portals.contains(pos) || !mayFill(level, pos, receptacle, allowUnownedCells)) continue;
if (adjacentCount(pos, crystals, portals) < 2) continue; if (adjacentCount(pos, crystals, portals) < 2) continue;
portals.add(pos); portals.add(pos);
if (portals.size() > MAX_PORTALS) return new Plan(List.of()); if (portals.size() > MAX_PORTALS) return new Plan(List.of());
@@ -97,6 +110,28 @@ public final class CrystalPortalNetwork {
} }
public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan) { public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan) {
return activate(level, receptacle, plan, null);
}
/** Repairs only existing, framed cells whose owner pointer was lost by an older live-view update. */
public static boolean recoverSavedOwners(ServerLevel level, BlockPos receptacle) {
if (!(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|| holder.getBook().isEmpty()) return false;
Plan plan = plan(level, receptacle, true);
if (!plan.valid()) return false;
for (BlockPos pos : plan.portalCells()) {
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| portal.receptacle() != null && !receptacle.equals(portal.receptacle())) return false;
}
for (BlockPos pos : plan.portalCells()) {
CrystalPortalBlockEntity portal = (CrystalPortalBlockEntity) level.getBlockEntity(pos);
if (portal.receptacle() == null) portal.setReceptacle(receptacle);
}
return true;
}
/** The player who inserted the book owns each newly placed portal cell for claim checks. */
public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan, Entity actor) {
if (!plan.valid() || !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder) if (!plan.valid() || !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|| holder.getBook().isEmpty()) return false; || holder.getBook().isEmpty()) return false;
List<BlockPos> placed = new ArrayList<>(); List<BlockPos> placed = new ArrayList<>();
@@ -106,28 +141,39 @@ public final class CrystalPortalNetwork {
return false; return false;
} }
if (level.getBlockState(pos).isAir()) { if (level.getBlockState(pos).isAir()) {
if (!level.setBlock(pos, ModBlocks.CRYSTAL_PORTAL.get().defaultBlockState(), 3)) { BlockSnapshot snapshot = actor == null ? null : BlockSnapshot.create(level.dimension(), level, pos);
BlockState prior = level.getBlockState(pos);
// Defer neighbor notifications until the entire opening exists.
// Otherwise the first cells see an incomplete brace and the
// portal's neighbor validator removes them during activation.
if (!level.setBlock(pos, ModBlocks.CRYSTAL_PORTAL.get().defaultBlockState(), 2)) {
rollback(level, placed); rollback(level, placed);
return false; return false;
} }
if (snapshot != null) {
var event = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(event);
if (event.isCanceled()) {
snapshot.restore();
rollback(level, placed);
return false;
}
}
placed.add(pos); placed.add(pos);
} }
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) { if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) {
portal.setReceptacle(receptacle); portal.setReceptacle(receptacle);
level.scheduleTick(pos, ModBlocks.CRYSTAL_PORTAL.get(), 40);
} else { } else {
rollback(level, placed); rollback(level, placed);
return false; return false;
} }
} }
// All cells are now present, so the same bracing rule used for the orientSurfaces(level, plan);
// Immersive Portals surface can orient the two-sided fallback model. for (BlockPos pos : plan.portalCells()) {
for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(level, plan)) { if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
for (BlockPos pos : surface.cells()) { || !receptacle.equals(portal.receptacle())) {
BlockState state = level.getBlockState(pos); rollback(level, placed);
Direction.Axis axis = surface.normal().getAxis(); return false;
if (state.getValue(CrystalPortalBlock.AXIS) != axis)
level.setBlock(pos, state.setValue(CrystalPortalBlock.AXIS, axis), 2);
} }
} }
return true; return true;
@@ -148,13 +194,24 @@ public final class CrystalPortalNetwork {
} }
/** One bounded repair pass per receptacle, rather than a full flood per portal cell. */ /** One bounded repair pass per receptacle, rather than a full flood per portal cell. */
public static void reconcile(ServerLevel level, BlockPos receptacle) { public static boolean reconcile(ServerLevel level, BlockPos receptacle) {
Plan plan = plan(level, receptacle); Plan plan = plan(level, receptacle);
if (!plan.valid()) { if (!plan.valid()) {
shutdown(level, receptacle); // Neighboring chunks can still be loading when the receptacle first
return; // ticks after a restart. An incomplete view is not a broken frame.
if (!plan.waitingForChunks()) shutdown(level, receptacle);
return false;
} }
Set<BlockPos> wanted = new HashSet<>(plan.portalCells()); Set<BlockPos> wanted = new HashSet<>(plan.portalCells());
// A background pass must not create blocks without the owner present:
// that would bypass the placement/protection event used at activation.
for (BlockPos pos : wanted) {
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| !receptacle.equals(portal.receptacle())) {
shutdown(level, receptacle);
return false;
}
}
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS), for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) { receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) {
if (!level.hasChunkAt(pos)) continue; if (!level.hasChunkAt(pos)) continue;
@@ -162,29 +219,64 @@ public final class CrystalPortalNetwork {
&& receptacle.equals(portal.receptacle()) && !wanted.contains(pos)) && receptacle.equals(portal.receptacle()) && !wanted.contains(pos))
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
} }
activate(level, receptacle, plan); orientSurfaces(level, plan);
return true;
} }
public static boolean validCell(ServerLevel level, BlockPos pos, BlockPos receptacle) { private static void orientSurfaces(ServerLevel level, Plan plan) {
for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(level, plan)) {
for (BlockPos pos : surface.cells()) {
BlockState state = level.getBlockState(pos);
Direction.Axis axis = surface.normal().getAxis();
if (state.getValue(CrystalPortalBlock.AXIS) != axis)
updatePortalState(level, pos, state.setValue(CrystalPortalBlock.AXIS, axis));
}
}
}
/** The vanilla-style surface remains visible until its optional live view is ready. */
public static void setLiveView(ServerLevel level, Plan plan, boolean live) {
for (BlockPos pos : plan.portalCells()) {
BlockState state = level.getBlockState(pos);
if (state.is(ModBlocks.CRYSTAL_PORTAL.get()) && state.getValue(CrystalPortalBlock.LIVE_VIEW) != live)
updatePortalState(level, pos, state.setValue(CrystalPortalBlock.LIVE_VIEW, live));
}
}
private static void updatePortalState(ServerLevel level, BlockPos pos, BlockState replacement) {
BlockPos owner = level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
? portal.receptacle() : null;
if (!level.setBlock(pos, replacement, 2) || owner == null) return;
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
&& !owner.equals(portal.receptacle())) portal.setReceptacle(owner);
}
public static CellStatus cellStatus(ServerLevel level, BlockPos pos, BlockPos receptacle) {
if (Math.abs(receptacle.getX() - pos.getX()) > CLEANUP_RADIUS if (Math.abs(receptacle.getX() - pos.getX()) > CLEANUP_RADIUS
|| Math.abs(receptacle.getY() - pos.getY()) > CLEANUP_RADIUS || Math.abs(receptacle.getY() - pos.getY()) > CLEANUP_RADIUS
|| Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS || Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS) return CellStatus.INVALID;
|| !level.hasChunkAt(receptacle) if (!level.hasChunkAt(receptacle)) return CellStatus.WAITING_FOR_CHUNKS;
|| !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|| holder.getBook().isEmpty()) return false;
BlockState state = level.getBlockState(receptacle); BlockState state = level.getBlockState(receptacle);
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get()) || !level.getBlockState(receptacle.relative( if (!state.is(ModBlocks.BOOK_RECEPTACLE.get())) return CellStatus.INVALID;
state.getValue(BookReceptacleBlock.FACING).getOpposite())).is(ModBlocks.CRYSTAL_BLOCK.get())) BlockPos base = receptacle.relative(state.getValue(BookReceptacleBlock.FACING).getOpposite());
return false; if (!level.hasChunkAt(base)) return CellStatus.WAITING_FOR_CHUNKS;
if (!level.getBlockState(base).is(ModBlocks.CRYSTAL_BLOCK.get())) return CellStatus.INVALID;
if (!(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder))
return CellStatus.WAITING_FOR_CHUNKS;
if (holder.getBook().isEmpty()) return CellStatus.INVALID;
// A neighboring chunk may hold one of the braces. Do not tear down a
// persisted portal until all six neighbors are available to inspect.
for (Direction direction : Direction.values())
if (!level.hasChunkAt(pos.relative(direction))) return CellStatus.WAITING_FOR_CHUNKS;
for (Direction.Axis axis : Direction.Axis.values()) { for (Direction.Axis axis : Direction.Axis.values()) {
// Quick tension check before the bounded network/path revalidation. // Quick tension check before the bounded network/path revalidation.
if (!axisBraced(level, pos, axis)) continue; if (!axisBraced(level, pos, axis)) continue;
int other = 0; int other = 0;
for (Direction.Axis alternative : Direction.Axis.values()) for (Direction.Axis alternative : Direction.Axis.values())
if (alternative != axis && axisBraced(level, pos, alternative)) other++; if (alternative != axis && axisBraced(level, pos, alternative)) other++;
if (other > 0) return true; if (other > 0) return CellStatus.VALID;
} }
return false; return CellStatus.INVALID;
} }
private static boolean axisBraced(ServerLevel level, BlockPos pos, Direction.Axis axis) { private static boolean axisBraced(ServerLevel level, BlockPos pos, Direction.Axis axis) {
@@ -205,11 +297,15 @@ public final class CrystalPortalNetwork {
} }
private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle) { private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle) {
return mayFill(level, pos, receptacle, false);
}
private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle, boolean allowUnowned) {
if (!level.hasChunkAt(pos) || !level.isInWorldBounds(pos)) return false; if (!level.hasChunkAt(pos) || !level.isInWorldBounds(pos)) return false;
BlockState state = level.getBlockState(pos); BlockState state = level.getBlockState(pos);
return state.isAir() || state.is(ModBlocks.CRYSTAL_PORTAL.get()) return state.isAir() || state.is(ModBlocks.CRYSTAL_PORTAL.get())
&& level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal && level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
&& receptacle.equals(portal.receptacle()); && (receptacle.equals(portal.receptacle()) || allowUnowned && portal.receptacle() == null);
} }
private static int adjacentCount(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) { private static int adjacentCount(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
@@ -1,12 +1,33 @@
package net.dnorberg.rivencraft.portal; package net.dnorberg.rivencraft.portal;
/** Server tick counters restart with the process; old saved values cannot block new sessions. */ import java.util.Map;
import java.util.WeakHashMap;
import net.minecraft.server.level.ServerPlayer;
/** Session-only portal cooldowns: tick counters must never be saved with players. */
public final class PortalTravelCooldown { public final class PortalTravelCooldown {
public static final long TICKS = 40; public static final long TICKS = 40;
private static final Map<ServerPlayer, Long> LAST_USE = new WeakHashMap<>();
private PortalTravelCooldown() {} private PortalTravelCooldown() {}
public static boolean blocked(long now, long previous) { public static boolean blocked(long now, long previous) {
return previous >= 0 && now >= previous && now - previous < TICKS; return previous >= 0 && now >= previous && now - previous < TICKS;
} }
public static boolean blocked(ServerPlayer player, long now) {
return blocked(now, LAST_USE.getOrDefault(player, -1L));
}
public static void mark(ServerPlayer player, long now) {
LAST_USE.put(player, now);
}
public static void forget(ServerPlayer player) {
LAST_USE.remove(player);
}
public static void clear() {
LAST_USE.clear();
}
} }
@@ -0,0 +1,64 @@
package net.dnorberg.rivencraft.portal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
/** Defers block-collision travel until the movement packet that entered the portal has finished. */
public final class PortalTravelQueue {
private static final Map<ServerPlayer, Pending> PENDING = new IdentityHashMap<>();
private record Pending(ServerLevel source, BlockPos portal, BlockPos receptacle,
ItemStack book, long queuedTick) {}
private record Travel(ServerPlayer player, Pending pending) {}
private PortalTravelQueue() {}
public static void enqueue(ServerPlayer player, ServerLevel source, BlockPos portal,
BlockPos receptacle, ItemStack book, long now) {
PENDING.putIfAbsent(player, new Pending(source, portal.immutable(), receptacle.immutable(),
book.copyWithCount(1), now));
}
public static void process(MinecraftServer server) {
List<Travel> ready = new ArrayList<>();
Iterator<Map.Entry<ServerPlayer, Pending>> iterator = PENDING.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
if (server.getTickCount() <= entry.getValue().queuedTick()) continue;
ready.add(new Travel(entry.getKey(), entry.getValue()));
iterator.remove();
}
// Travel can itself cause block-collision callbacks, so do not keep an
// iterator over the pending map while changing the player's dimension.
for (Travel travel : ready) {
ServerPlayer player = travel.player();
Pending pending = travel.pending();
if (server.getPlayerList().getPlayer(player.getUUID()) != player
|| !player.isAlive() || player.serverLevel() != pending.source()
|| !(pending.source().getBlockEntity(pending.portal()) instanceof CrystalPortalBlockEntity portal)
|| !pending.receptacle().equals(portal.receptacle())
|| !(pending.source().getBlockEntity(pending.receptacle()) instanceof BookReceptacleBlockEntity holder)
|| !ItemStack.isSameItemSameComponents(holder.getBook(), pending.book())) continue;
if (pending.book().is(ModItems.LINKING_BOOK.get()))
LinkingBookItem.usePortalBook(player, pending.book());
else if (pending.book().is(ModItems.DESCRIPTIVE_BOOK.get()))
DescriptiveBookItem.usePortalBook(player, pending.book());
}
}
public static void forget(ServerPlayer player) { PENDING.remove(player); }
public static void clear() { PENDING.clear(); }
}
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity; import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity; import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
@@ -31,6 +32,9 @@ public final class ModBlockEntities {
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<InkMixerBlockEntity>> INK_MIXER = public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<InkMixerBlockEntity>> INK_MIXER =
BLOCK_ENTITIES.register("ink_mixer", () -> BlockEntityType.Builder BLOCK_ENTITIES.register("ink_mixer", () -> BlockEntityType.Builder
.of(InkMixerBlockEntity::new, ModBlocks.INK_MIXER.get()).build(null)); .of(InkMixerBlockEntity::new, ModBlocks.INK_MIXER.get()).build(null));
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<LinkModifierBlockEntity>> LINK_MODIFIER =
BLOCK_ENTITIES.register("link_modifier", () -> BlockEntityType.Builder
.of(LinkModifierBlockEntity::new, ModBlocks.LINK_MODIFIER.get()).build(null));
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<StarFissureBlockEntity>> STAR_FISSURE = public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<StarFissureBlockEntity>> STAR_FISSURE =
BLOCK_ENTITIES.register("star_fissure", () -> BlockEntityType.Builder BLOCK_ENTITIES.register("star_fissure", () -> BlockEntityType.Builder
.of(StarFissureBlockEntity::new, ModBlocks.STAR_FISSURE.get()).build(null)); .of(StarFissureBlockEntity::new, ModBlocks.STAR_FISSURE.get()).build(null));
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.BookBinderBlock; import net.dnorberg.rivencraft.block.BookBinderBlock;
import net.dnorberg.rivencraft.block.WritingDeskBlock; import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.InkMixerBlock; import net.dnorberg.rivencraft.block.InkMixerBlock;
import net.dnorberg.rivencraft.block.LinkModifierBlock;
import net.dnorberg.rivencraft.block.BuddingCrystalBlock; import net.dnorberg.rivencraft.block.BuddingCrystalBlock;
import net.dnorberg.rivencraft.block.StarFissureBlock; import net.dnorberg.rivencraft.block.StarFissureBlock;
import net.dnorberg.rivencraft.block.DecayBlock; import net.dnorberg.rivencraft.block.DecayBlock;
@@ -40,6 +41,9 @@ public final class ModBlocks {
"ink_mixer", InkMixerBlock::new, "ink_mixer", InkMixerBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion() BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
); );
public static final DeferredBlock<LinkModifierBlock> LINK_MODIFIER = BLOCKS.registerBlock(
"link_modifier", LinkModifierBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block", public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).mapColor(MapColor.COLOR_BLUE)); Block::new, BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).mapColor(MapColor.COLOR_BLUE));
public static final DeferredBlock<BookReceptacleBlock> BOOK_RECEPTACLE = BLOCKS.registerBlock("book_receptacle", public static final DeferredBlock<BookReceptacleBlock> BOOK_RECEPTACLE = BLOCKS.registerBlock("book_receptacle",
@@ -70,8 +74,8 @@ public final class ModBlocks {
DecayBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.STONE) DecayBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.STONE)
.strength(5.0F, 10.0F).noLootTable()); .strength(5.0F, 10.0F).noLootTable());
public static final DeferredBlock<CloudPlatformBlock> CLOUD_PLATFORM = BLOCKS.registerBlock("cloud_platform", public static final DeferredBlock<CloudPlatformBlock> CLOUD_PLATFORM = BLOCKS.registerBlock("cloud_platform",
CloudPlatformBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.WHITE_STAINED_GLASS) CloudPlatformBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)
.strength(1.5F).noOcclusion().noLootTable()); .mapColor(MapColor.COLOR_BLUE).strength(0.3F).noLootTable());
private ModBlocks() { private ModBlocks() {
} }
@@ -27,9 +27,12 @@ public final class ModCreativeTabs {
output.accept(ModItems.DESCRIPTIVE_BOOK.get()); output.accept(ModItems.DESCRIPTIVE_BOOK.get());
output.accept(ModItems.LINKING_BOOK.get()); output.accept(ModItems.LINKING_BOOK.get());
output.accept(ModItems.NOTEBOOK.get()); output.accept(ModItems.NOTEBOOK.get());
output.accept(ModItems.COLLATION_FOLDER.get());
output.accept(ModItems.SEALED_NOTEBOOK.get());
output.accept(ModItems.INK_VIAL.get()); output.accept(ModItems.INK_VIAL.get());
output.accept(ModItems.WRITING_DESK.get()); output.accept(ModItems.WRITING_DESK.get());
output.accept(ModItems.INK_MIXER.get()); output.accept(ModItems.INK_MIXER.get());
output.accept(ModItems.LINK_MODIFIER.get());
output.accept(ModItems.BOOK_BINDER.get()); output.accept(ModItems.BOOK_BINDER.get());
output.accept(ModItems.CRYSTAL_BLOCK.get()); output.accept(ModItems.CRYSTAL_BLOCK.get());
output.accept(ModItems.BOOK_RECEPTACLE.get()); output.accept(ModItems.BOOK_RECEPTACLE.get());
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.MobCategory;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
public final class ModEntities {
private static final DeferredRegister<EntityType<?>> ENTITIES =
DeferredRegister.create(Registries.ENTITY_TYPE, Rivencraft.MOD_ID);
public static final DeferredHolder<EntityType<?>, EntityType<DroppedBookEntity>> DROPPED_BOOK =
ENTITIES.register("dropped_book", () -> EntityType.Builder
.<DroppedBookEntity>of(DroppedBookEntity::new, MobCategory.MISC)
.sized(0.65F, 0.4F)
.clientTrackingRange(10)
.updateInterval(20)
.build(Rivencraft.MOD_ID + ":dropped_book"));
private ModEntities() {
}
public static void register(IEventBus bus) {
ENTITIES.register(bus);
}
}
@@ -5,6 +5,8 @@ import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem; import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.NotebookItem; import net.dnorberg.rivencraft.item.NotebookItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.item.SealedNotebookItem;
import net.dnorberg.rivencraft.item.InkVialItem; import net.dnorberg.rivencraft.item.InkVialItem;
import net.minecraft.world.item.Item; import net.minecraft.world.item.Item;
import net.minecraft.world.item.BlockItem; import net.minecraft.world.item.BlockItem;
@@ -35,6 +37,10 @@ public final class ModItems {
NotebookItem::new, NotebookItem::new,
new Item.Properties().stacksTo(1) new Item.Properties().stacksTo(1)
); );
public static final DeferredItem<CollationFolderItem> COLLATION_FOLDER = ITEMS.registerItem(
"collation_folder", CollationFolderItem::new, new Item.Properties().stacksTo(1));
public static final DeferredItem<SealedNotebookItem> SEALED_NOTEBOOK = ITEMS.registerItem(
"sealed_notebook", SealedNotebookItem::new, new Item.Properties().stacksTo(16));
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem( public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
"ink_vial", "ink_vial",
InkVialItem::new, InkVialItem::new,
@@ -43,6 +49,7 @@ public final class ModItems {
public static final DeferredItem<BlockItem> WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK); public static final DeferredItem<BlockItem> WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK);
public static final DeferredItem<BlockItem> BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER); public static final DeferredItem<BlockItem> BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER);
public static final DeferredItem<BlockItem> INK_MIXER = ITEMS.registerSimpleBlockItem(ModBlocks.INK_MIXER); public static final DeferredItem<BlockItem> INK_MIXER = ITEMS.registerSimpleBlockItem(ModBlocks.INK_MIXER);
public static final DeferredItem<BlockItem> LINK_MODIFIER = ITEMS.registerSimpleBlockItem(ModBlocks.LINK_MODIFIER);
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK = ITEMS.registerSimpleBlockItem(ModBlocks.CRYSTAL_BLOCK); public static final DeferredItem<BlockItem> CRYSTAL_BLOCK = ITEMS.registerSimpleBlockItem(ModBlocks.CRYSTAL_BLOCK);
public static final DeferredItem<BlockItem> BOOK_RECEPTACLE = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_RECEPTACLE); public static final DeferredItem<BlockItem> BOOK_RECEPTACLE = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_RECEPTACLE);
public static final DeferredItem<BlockItem> BUDDING_CRYSTAL = ITEMS.registerSimpleBlockItem(ModBlocks.BUDDING_CRYSTAL); public static final DeferredItem<BlockItem> BUDDING_CRYSTAL = ITEMS.registerSimpleBlockItem(ModBlocks.BUDDING_CRYSTAL);
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.menu.WritingDeskMenu; import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.menu.BookBinderMenu; import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.menu.InkMixerMenu; import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import net.minecraft.world.inventory.MenuType; import net.minecraft.world.inventory.MenuType;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
@@ -24,6 +25,8 @@ public final class ModMenus {
MENUS.register("book_binder", () -> IMenuTypeExtension.create(BookBinderMenu::new)); MENUS.register("book_binder", () -> IMenuTypeExtension.create(BookBinderMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<InkMixerMenu>> INK_MIXER = public static final DeferredHolder<MenuType<?>, MenuType<InkMixerMenu>> INK_MIXER =
MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new)); MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<LinkModifierMenu>> LINK_MODIFIER =
MENUS.register("link_modifier", () -> IMenuTypeExtension.create(LinkModifierMenu::new));
private ModMenus() { private ModMenus() {
} }
@@ -14,6 +14,8 @@ public final class ModSounds {
DeferredRegister.create(Registries.SOUND_EVENT, Rivencraft.MOD_ID); DeferredRegister.create(Registries.SOUND_EVENT, Rivencraft.MOD_ID);
public static final DeferredHolder<SoundEvent, SoundEvent> LINK = register("linking.link"); public static final DeferredHolder<SoundEvent, SoundEvent> LINK = register("linking.link");
public static final DeferredHolder<SoundEvent, SoundEvent> LINK_DISARM = register("linking.link-disarm");
public static final DeferredHolder<SoundEvent, SoundEvent> LINK_INTRA = register("linking.link-intra");
public static final DeferredHolder<SoundEvent, SoundEvent> LINK_FISSURE = register("linking.link-fissure"); public static final DeferredHolder<SoundEvent, SoundEvent> LINK_FISSURE = register("linking.link-fissure");
public static final DeferredHolder<SoundEvent, SoundEvent> LINK_PORTAL = register("linking.link-portal"); public static final DeferredHolder<SoundEvent, SoundEvent> LINK_PORTAL = register("linking.link-portal");
public static final DeferredHolder<SoundEvent, SoundEvent> METEOR_ROAR = register("entity.meteor.roar"); public static final DeferredHolder<SoundEvent, SoundEvent> METEOR_ROAR = register("entity.meteor.roar");
@@ -9,6 +9,7 @@ import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
@@ -24,7 +25,7 @@ public final class WritingDeskCatalog {
for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) { for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) {
ItemStack collection = desk.getItem(slot); ItemStack collection = desk.getItem(slot);
if (collection.is(ModItems.PAGE.get())) add(unique, collection); if (collection.is(ModItems.PAGE.get())) add(unique, collection);
if (collection.is(ModItems.NOTEBOOK.get())) { if (CollationFolderItem.isCollection(collection)) {
List<ItemStack> pages = collection.getOrDefault( List<ItemStack> pages = collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages(); ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
pages.forEach(page -> add(unique, page)); pages.forEach(page -> add(unique, page));
@@ -0,0 +1,8 @@
{
"variants": {
"facing=north": { "model": "rivencraft:block/link_modifier" },
"facing=east": { "model": "rivencraft:block/link_modifier", "y": 90 },
"facing=south": { "model": "rivencraft:block/link_modifier", "y": 180 },
"facing=west": { "model": "rivencraft:block/link_modifier", "y": 270 }
}
}
@@ -15,8 +15,27 @@
"item.rivencraft.link_property.disarm": "Disarm", "item.rivencraft.link_property.disarm": "Disarm",
"item.rivencraft.link_property.maintain_momentum": "Maintain Momentum", "item.rivencraft.link_property.maintain_momentum": "Maintain Momentum",
"item.rivencraft.link_property.intra_linking": "Intra Linking", "item.rivencraft.link_property.intra_linking": "Intra Linking",
"item.rivencraft.link_property.intra_linking_only": "Intra-Linking Only",
"item.rivencraft.link_property.relative_link": "Relative Link",
"item.rivencraft.link_property.disable_platform": "Generate Platform: Off",
"item.rivencraft.page.unresolved": "Unresolved Symbol Page", "item.rivencraft.page.unresolved": "Unresolved Symbol Page",
"item.rivencraft.notebook": "Notebook", "item.rivencraft.notebook": "Notebook",
"item.rivencraft.collation_folder": "Collation Folder",
"item.rivencraft.sealed_notebook": "Sealed Notebook",
"block.rivencraft.link_modifier": "Link Modifier",
"container.rivencraft.link_modifier": "Link Modifier",
"screen.rivencraft.link_modifier.title": "Book title",
"screen.rivencraft.link_modifier.seed": "Age seed",
"screen.rivencraft.link_modifier.seed_warning": "Changing the seed preserves existing chunks but changes newly generated terrain. Press Enter to apply. The Age must be empty.",
"message.rivencraft.link_modifier.seed_changed": "Age seed changed. Existing chunks remain; newly generated chunks use the new seed.",
"message.rivencraft.link_modifier.seed_failed": "Could not change seed. Use a signed whole number and ensure no players are in the Age.",
"screen.rivencraft.link_modifier.retire": "Retire Age",
"screen.rivencraft.link_modifier.confirm": "Confirm!",
"message.rivencraft.link_modifier.delete.retired": "Age retired. Its terrain was archived outside the active world.",
"message.rivencraft.link_modifier.delete.already_dead": "This Age is already retired.",
"message.rivencraft.link_modifier.delete.occupied": "Cannot retire an Age while players are inside it.",
"message.rivencraft.link_modifier.delete.unload_failed": "Could not unload this Age; its data is unchanged.",
"message.rivencraft.link_modifier.delete.storage_failed": "Could not archive this Age's data; it was not retired.",
"item.rivencraft.notebook.pages": "Pages: %s/%s", "item.rivencraft.notebook.pages": "Pages: %s/%s",
"item.rivencraft.notebook.unresolved": "Unresolved pages: %s", "item.rivencraft.notebook.unresolved": "Unresolved pages: %s",
"item.rivencraft.ink_vial": "Ink Vial", "item.rivencraft.ink_vial": "Ink Vial",
@@ -26,7 +45,7 @@
"container.rivencraft.book_binder": "Book Binder", "container.rivencraft.book_binder": "Book Binder",
"block.rivencraft.ink_mixer": "Ink Mixer", "block.rivencraft.ink_mixer": "Ink Mixer",
"block.rivencraft.decay": "Decay", "block.rivencraft.decay": "Decay",
"block.rivencraft.cloud_platform": "Cloud Platform", "block.rivencraft.cloud_platform": "Crystal Platform",
"container.rivencraft.ink_mixer": "Ink Mixer", "container.rivencraft.ink_mixer": "Ink Mixer",
"screen.rivencraft.ink_mixer.ink": "Ink: %s/%s", "screen.rivencraft.ink_mixer.ink": "Ink: %s/%s",
"screen.rivencraft.ink_mixer.additive_hint": "Click the basin with an additive; right-click adds one.", "screen.rivencraft.ink_mixer.additive_hint": "Click the basin with an additive; right-click adds one.",
@@ -88,6 +107,7 @@
"message.rivencraft.linking_book.missing_dimension": "That dimension is unavailable", "message.rivencraft.linking_book.missing_dimension": "That dimension is unavailable",
"message.rivencraft.linking_book.unsafe": "No safe destination was found near the link", "message.rivencraft.linking_book.unsafe": "No safe destination was found near the link",
"message.rivencraft.linking_book.intra_required": "This book needs an Intra Linking panel for same-dimension travel", "message.rivencraft.linking_book.intra_required": "This book needs an Intra Linking panel for same-dimension travel",
"message.rivencraft.linking_book.intra_only": "This book can only link within its current dimension",
"message.rivencraft.descriptive_book.unwritten": "This Descriptive Book is unwritten", "message.rivencraft.descriptive_book.unwritten": "This Descriptive Book is unwritten",
"message.rivencraft.descriptive_book.created": "Created Age: %s", "message.rivencraft.descriptive_book.created": "Created Age: %s",
"message.rivencraft.descriptive_book.unavailable": "That Age is unavailable", "message.rivencraft.descriptive_book.unavailable": "That Age is unavailable",
@@ -341,6 +361,11 @@
"jei.rivencraft.writing.copy": "At a Writing Desk, select a stored Page, add paper and ink, then take the copy. The source Page stays.", "jei.rivencraft.writing.copy": "At a Writing Desk, select a stored Page, add paper and ink, then take the copy. The source Page stays.",
"jei.rivencraft.writing.bind": "At a Book Binder, name the Age and add a cover plus Link Panel. Optional symbol Pages follow the panel.", "jei.rivencraft.writing.bind": "At a Book Binder, name the Age and add a cover plus Link Panel. Optional symbol Pages follow the panel.",
"jei.rivencraft.writing.linking_book": "Craft one Link Panel Page with leather to make an unwritten Linking Book. Open it to name and bind it.", "jei.rivencraft.writing.linking_book": "Craft one Link Panel Page with leather to make an unwritten Linking Book. Open it to name and bind it.",
"rivencraft.ponder.crystal_portal.header": "Building a Crystal Portal",
"rivencraft.ponder.crystal_portal.text_1": "Build a closed crystal frame with an open center.",
"rivencraft.ponder.crystal_portal.text_2": "Attach a Book Receptacle to one crystal, facing outward.",
"rivencraft.ponder.crystal_portal.text_3": "Insert a bound Linking or Descriptive Book to activate the portal.",
"rivencraft.ponder.crystal_portal.text_4": "Step through the portal. Remove the book to close it.",
"itemGroup.rivencraft": "Rivencraft", "itemGroup.rivencraft": "Rivencraft",
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages", "itemGroup.rivencraft.pages.link": "Rivencraft Link Pages",
"itemGroup.rivencraft.pages.terrain": "Rivencraft Terrain Pages", "itemGroup.rivencraft.pages.terrain": "Rivencraft Terrain Pages",
@@ -364,5 +389,21 @@
"symbol.rivencraft.corrupted.medium": "Corrupted Page", "symbol.rivencraft.corrupted.medium": "Corrupted Page",
"symbol.rivencraft.corrupted.medium.description": "The writing shifts beyond comprehension.", "symbol.rivencraft.corrupted.medium.description": "The writing shifts beyond comprehension.",
"symbol.rivencraft.corrupted.small": "Corrupted Page", "symbol.rivencraft.corrupted.small": "Corrupted Page",
"symbol.rivencraft.corrupted.small.description": "The writing shifts beyond comprehension." "symbol.rivencraft.corrupted.small.description": "The writing shifts beyond comprehension.",
"advancement.rivencraft.root.title": "The Ending Has Not Yet Been Written",
"advancement.rivencraft.root.description": "Begin the Art of writing and linking Ages.",
"advancement.rivencraft.page.title": "A Strange Page",
"advancement.rivencraft.page.description": "Find a Page marked with the language of Ages.",
"advancement.rivencraft.linking_book.title": "A Way Back",
"advancement.rivencraft.linking_book.description": "Obtain a Linking Book and remember where you came from.",
"advancement.rivencraft.link_panel.title": "D'ni Ink",
"advancement.rivencraft.link_panel.description": "Create a Link Panel at the Ink Mixer.",
"advancement.rivencraft.copy_page.title": "The Art of Writing",
"advancement.rivencraft.copy_page.description": "Copy a Page at the Writing Desk.",
"advancement.rivencraft.bind_age.title": "A Book of Ages",
"advancement.rivencraft.bind_age.description": "Bind a completed Descriptive Book.",
"advancement.rivencraft.enter_age.title": "The Journey Begins",
"advancement.rivencraft.enter_age.description": "Safely link into an Age you have written.",
"advancement.rivencraft.open_portal.title": "A Window Between Ages",
"advancement.rivencraft.open_portal.description": "Activate a Crystal Portal with a bound book."
} }
@@ -39,6 +39,28 @@
"down": {"texture":"#press","uv":[8.5,0,10,7]} "down": {"texture":"#press","uv":[8.5,0,10,7]}
} }
}, },
{
"from": [6, 10, 1], "to": [7, 14, 15],
"faces": {
"north": {"texture":"#press","uv":[7,13,7.5,15]},
"east": {"texture":"#press","uv":[7.5,13,14.5,15]},
"south": {"texture":"#press","uv":[14.5,13,15,15]},
"west": {"texture":"#press","uv":[0,13,7,15]},
"up": {"texture":"#press","uv":[0,6,0.5,13]},
"down": {"texture":"#press","uv":[0.5,6,1,13]}
}
},
{
"from": [9, 10, 1], "to": [10, 14, 15],
"faces": {
"north": {"texture":"#press","uv":[7,13,7.5,15]},
"east": {"texture":"#press","uv":[0,13,7,15]},
"south": {"texture":"#press","uv":[14.5,13,15,15]},
"west": {"texture":"#press","uv":[7.5,13,14.5,15]},
"up": {"texture":"#press","uv":[0,6,0.5,13]},
"down": {"texture":"#press","uv":[0.5,6,1,13]}
}
},
{ {
"from": [3, 11, 3], "to": [13, 12, 4], "from": [3, 11, 3], "to": [13, 12, 4],
"faces": { "faces": {
@@ -56,6 +78,17 @@
"up": {"texture":"#press","uv":[0.5,15,5.5,15.5]}, "up": {"texture":"#press","uv":[0.5,15,5.5,15.5]},
"down": {"texture":"#press","uv":[5.5,15,10.5,15.5]} "down": {"texture":"#press","uv":[5.5,15,10.5,15.5]}
} }
},
{
"from": [7, 10, 4.5], "to": [9, 15, 11.5],
"faces": {
"north": {"texture":"#press","uv":[4.5,10.5,5.5,13]},
"east": {"texture":"#press","uv":[1,10.5,4.5,13]},
"south": {"texture":"#press","uv":[9,10,10,13]},
"west": {"texture":"#press","uv":[5.5,10.5,9,13]},
"up": {"texture":"#press","uv":[4.5,7,5.5,10.5]},
"down": {"texture":"#press","uv":[5.5,7,6.5,10.5]}
}
} }
] ]
} }
@@ -1 +1 @@
{"parent":"minecraft:block/cube_all","render_type":"minecraft:translucent","textures":{"all":"rivencraft:block/cloud_platform"}} {"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/crystal_block"}}
@@ -1 +1 @@
{"textures":{"portal":"rivencraft:block/crystal_portal"},"elements":[{"from":[0,0,7.5],"to":[16,16,8.5],"shade":false,"faces":{"north":{"texture":"#portal"},"south":{"texture":"#portal"}}}]} {"render_type":"minecraft:translucent","ambientocclusion":false,"textures":{"particle":"rivencraft:block/crystal_portal","portal":"rivencraft:block/crystal_portal"},"elements":[{"from":[0,0,6],"to":[16,16,10],"shade":false,"faces":{"north":{"texture":"#portal"},"south":{"texture":"#portal"}}}]}
@@ -1,23 +1,971 @@
{ {
"parent": "minecraft:block/block", "parent": "minecraft:block/block",
"textures": { "textures": {
"particle": "rivencraft:block/ink_mixer_side", "0": "rivencraft:block/inkwell_base",
"side": "rivencraft:block/ink_mixer_side", "1": "rivencraft:block/inkwell_cup",
"top": "rivencraft:block/ink_mixer_top", "2": "rivencraft:block/ink",
"bottom": "rivencraft:block/ink_mixer_bottom" "3": "rivencraft:block/ink_glass",
"particle": "rivencraft:block/inkwell_base",
"paper": "minecraft:block/white_concrete"
}, },
"elements": [ "elements": [
{ {
"from": [0, 0, 0], "from": [
"to": [16, 12, 16], 0,
0,
0
],
"to": [
16,
9,
16
],
"faces": { "faces": {
"north": { "texture": "#side", "uv": [0, 4, 16, 16] }, "north": {
"east": { "texture": "#side", "uv": [0, 4, 16, 16] }, "texture": "#0",
"south": { "texture": "#side", "uv": [0, 4, 16, 16] }, "uv": [
"west": { "texture": "#side", "uv": [0, 4, 16, 16] }, 0,
"up": { "texture": "#top" }, 5,
"down": { "texture": "#bottom" } 4,
7
]
},
"east": {
"texture": "#0",
"uv": [
4,
5,
8,
7
]
},
"south": {
"texture": "#0",
"uv": [
8,
5,
12,
7
]
},
"west": {
"texture": "#0",
"uv": [
12,
5,
16,
7
]
},
"down": {
"texture": "#0",
"uv": [
8,
0,
12,
4
]
}
}
},
{
"from": [
0,
9,
0
],
"to": [
16,
12,
16
],
"faces": {
"north": {
"texture": "#0",
"uv": [
0,
4,
4,
5
]
},
"east": {
"texture": "#0",
"uv": [
4,
4,
8,
5
]
},
"south": {
"texture": "#0",
"uv": [
8,
4,
12,
5
]
},
"west": {
"texture": "#0",
"uv": [
12,
4,
16,
5
]
},
"up": {
"texture": "#0",
"uv": [
4,
0,
8,
4
]
}
}
},
{
"from": [
9,
12,
6
],
"to": [
13,
13,
10
],
"faces": {
"north": {
"texture": "#1",
"uv": [
0,
0,
4,
1
]
},
"east": {
"texture": "#1",
"uv": [
4,
0,
8,
1
]
},
"south": {
"texture": "#1",
"uv": [
8,
0,
12,
1
]
},
"west": {
"texture": "#1",
"uv": [
12,
0,
16,
1
]
}
}
},
{
"from": [
8,
13,
5
],
"to": [
14,
16,
6
],
"faces": {
"north": {
"texture": "#1",
"uv": [
7,
1,
13,
4
]
},
"east": {
"texture": "#1",
"uv": [
6,
1,
7,
4
]
},
"south": {
"texture": "#1",
"uv": [
0,
1,
6,
4
]
},
"west": {
"texture": "#1",
"uv": [
13,
1,
14,
4
]
},
"up": {
"texture": "#1",
"uv": [
0,
0,
6,
1
]
},
"down": {
"texture": "#1",
"uv": [
0,
0,
6,
1
]
}
}
},
{
"from": [
8,
13,
10
],
"to": [
14,
16,
11
],
"faces": {
"north": {
"texture": "#1",
"uv": [
0,
0,
6,
3
]
},
"east": {
"texture": "#1",
"uv": [
0,
0,
1,
3
]
},
"south": {
"texture": "#1",
"uv": [
0,
0,
6,
3
]
},
"west": {
"texture": "#1",
"uv": [
0,
0,
1,
3
]
},
"up": {
"texture": "#1",
"uv": [
0,
0,
6,
1
]
},
"down": {
"texture": "#1",
"uv": [
0,
0,
6,
1
]
}
}
},
{
"from": [
8,
13,
6
],
"to": [
9,
16,
10
],
"faces": {
"north": {
"texture": "#1",
"uv": [
0,
0,
1,
3
]
},
"east": {
"texture": "#1",
"uv": [
0,
0,
4,
3
]
},
"south": {
"texture": "#1",
"uv": [
0,
0,
1,
3
]
},
"west": {
"texture": "#1",
"uv": [
0,
0,
4,
3
]
},
"up": {
"texture": "#1",
"uv": [
0,
0,
1,
4
]
},
"down": {
"texture": "#1",
"uv": [
0,
0,
1,
4
]
}
}
},
{
"from": [
13,
13,
6
],
"to": [
14,
16,
10
],
"faces": {
"north": {
"texture": "#1",
"uv": [
0,
0,
1,
3
]
},
"east": {
"texture": "#1",
"uv": [
0,
0,
4,
3
]
},
"south": {
"texture": "#1",
"uv": [
0,
0,
1,
3
]
},
"west": {
"texture": "#1",
"uv": [
0,
0,
4,
3
]
},
"up": {
"texture": "#1",
"uv": [
0,
0,
1,
4
]
},
"down": {
"texture": "#1",
"uv": [
0,
0,
1,
4
]
}
}
},
{
"from": [
9,
14,
6
],
"to": [
13,
15,
10
],
"faces": {
"up": {
"texture": "#2",
"uv": [
0,
0,
4,
3
]
}
}
},
{
"from": [
7,
12,
2
],
"to": [
9,
14,
4
],
"rotation": {
"origin": [
8,
8,
8
],
"axis": "y",
"angle": 22.5
},
"faces": {
"north": {
"texture": "#3",
"uv": [
0,
0,
2,
2
]
},
"east": {
"texture": "#3",
"uv": [
2,
0,
4,
2
]
},
"south": {
"texture": "#3",
"uv": [
4,
0,
6,
2
]
},
"west": {
"texture": "#3",
"uv": [
6,
0,
8,
2
]
},
"up": {
"texture": "#3",
"uv": [
0,
2,
2,
4
]
},
"down": {
"texture": "#3",
"uv": [
0,
0,
2,
2
]
}
}
},
{
"from": [
1,
12,
6
],
"to": [
3,
14,
8
],
"rotation": {
"origin": [
8,
8,
8
],
"axis": "y",
"angle": -22.5
},
"faces": {
"north": {
"texture": "#3",
"uv": [
0,
0,
2,
2
]
},
"east": {
"texture": "#3",
"uv": [
2,
0,
4,
2
]
},
"south": {
"texture": "#3",
"uv": [
4,
0,
6,
2
]
},
"west": {
"texture": "#3",
"uv": [
6,
0,
8,
2
]
},
"up": {
"texture": "#3",
"uv": [
0,
2,
2,
4
]
},
"down": {
"texture": "#3",
"uv": [
0,
0,
2,
2
]
}
}
},
{
"from": [
7.5,
14,
2.5
],
"to": [
8.5,
15,
3.5
],
"rotation": {
"origin": [
8,
8,
8
],
"axis": "y",
"angle": 22.5
},
"faces": {
"north": {
"texture": "#3",
"uv": [
0,
0,
1,
1
]
},
"east": {
"texture": "#3",
"uv": [
2,
0,
3,
1
]
},
"south": {
"texture": "#3",
"uv": [
4,
0,
5,
1
]
},
"west": {
"texture": "#3",
"uv": [
6,
0,
7,
1
]
},
"up": {
"texture": "#3",
"uv": [
0,
2,
1,
3
]
},
"down": {
"texture": "#3",
"uv": [
0,
0,
1,
1
]
}
}
},
{
"from": [
1.5,
14,
6.5
],
"to": [
2.5,
15,
7.5
],
"rotation": {
"origin": [
8,
8,
8
],
"axis": "y",
"angle": -22.5
},
"faces": {
"north": {
"texture": "#3",
"uv": [
0,
0,
1,
1
]
},
"east": {
"texture": "#3",
"uv": [
2,
0,
3,
1
]
},
"south": {
"texture": "#3",
"uv": [
4,
0,
5,
1
]
},
"west": {
"texture": "#3",
"uv": [
6,
0,
7,
1
]
},
"up": {
"texture": "#3",
"uv": [
0,
2,
1,
3
]
},
"down": {
"texture": "#3",
"uv": [
0,
0,
1,
1
]
}
}
},
{
"from": [
4,
12,
5
],
"to": [
6,
14,
7
],
"rotation": {
"origin": [
8,
8,
8
],
"axis": "y",
"angle": 22.5
},
"faces": {
"north": {
"texture": "#3",
"uv": [
0,
0,
2,
2
]
},
"east": {
"texture": "#3",
"uv": [
2,
0,
4,
2
]
},
"south": {
"texture": "#3",
"uv": [
4,
0,
6,
2
]
},
"west": {
"texture": "#3",
"uv": [
6,
0,
8,
2
]
},
"up": {
"texture": "#3",
"uv": [
0,
2,
2,
4
]
},
"down": {
"texture": "#3",
"uv": [
0,
0,
2,
2
]
}
}
},
{
"from": [
4.5,
14,
5.5
],
"to": [
5.5,
15,
6.5
],
"rotation": {
"origin": [
8,
8,
8
],
"axis": "y",
"angle": 22.5
},
"faces": {
"north": {
"texture": "#3",
"uv": [
0,
0,
1,
1
]
},
"east": {
"texture": "#3",
"uv": [
2,
0,
3,
1
]
},
"south": {
"texture": "#3",
"uv": [
4,
0,
5,
1
]
},
"west": {
"texture": "#3",
"uv": [
6,
0,
7,
1
]
},
"up": {
"texture": "#3",
"uv": [
0,
2,
1,
3
]
},
"down": {
"texture": "#3",
"uv": [
0,
0,
1,
1
]
}
}
},
{
"from": [
1,
12.01,
2
],
"to": [
7,
12.16,
11
],
"faces": {
"north": {
"texture": "#paper"
},
"east": {
"texture": "#paper"
},
"south": {
"texture": "#paper"
},
"west": {
"texture": "#paper"
},
"up": {
"texture": "#paper"
},
"down": {
"texture": "#paper"
}
} }
} }
] ],
"render_type": "minecraft:cutout"
} }
@@ -0,0 +1,120 @@
{
"parent": "block/block",
"textures": {
"particle": "rivencraft:block/link_modifier_base",
"0": "rivencraft:block/link_modifier_base",
"1": "rivencraft:block/link_modifier_details"
},
"elements": [
{
"from": [ 0.0, 0.0, 0.0 ],
"to": [ 16.0, 12.0, 16.0 ],
"faces": {
"north": { "texture": "#0", "uv": [ 0.0, 4.0, 4.0, 7.0 ] },
"east": { "texture": "#0", "uv": [ 4.0, 4.0, 8.0, 7.0 ] },
"south": { "texture": "#0", "uv": [ 8.0, 4.0, 12.0, 7.0 ] },
"west": { "texture": "#0", "uv": [ 12.0, 4.0, 16.0, 7.0 ] },
"up": { "texture": "#0", "uv": [ 4.0, 0.0, 8.0, 4.0 ] },
"down": { "texture": "#0", "uv": [ 8.0, 0.0, 12.0, 4.0 ] }
}
},
{
"from": [ 1.5, 12.0, 11.0 ],
"to": [ 3.5, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 3.7, 12.0, 11.0 ],
"to": [ 5.7, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 5.9, 12.0, 11.0 ],
"to": [ 7.9, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 8.1, 12.0, 11.0 ],
"to": [ 10.1, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 10.3, 12.0, 11.0 ],
"to": [ 12.3, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 12.5, 12.0, 11.0 ],
"to": [ 14.5, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 2.0, 9.4, 1.0 ],
"to": [ 14.0, 12.4, 8.0 ],
"rotation": { "origin": [ 8.0, 8.0, 8.0 ], "axis": "x", "angle": 22.5 },
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 4.0, 6.0, 5.5 ] },
"east": { "texture": "#1", "uv": [ 6.0, 4.0, 9.5, 5.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 6.0, 6.0, 7.5 ] },
"west": { "texture": "#1", "uv": [ 6.0, 6.0, 9.5, 7.5 ] },
"up": { "texture": "#1", "uv": [ 0.0, 0.0, 6.0, 3.5 ] },
"down": { "texture": "#1", "uv": [ 6.0, 0.0, 12.0, 3.5 ] }
}
},
{
"from": [ 4.0, 11.5, 3.5 ],
"to": [ 12.0, 15.5, 5.5 ],
"rotation": { "origin": [ 8.0, 8.0, 8.0 ], "axis": "x", "angle": 22.5 },
"faces": {
"north": { "texture": "#1", "uv": [ 1.0, 9.0, 5.0, 11.0 ] },
"east": { "texture": "#1", "uv": [ 0.0, 8.0, 1.0, 11.0 ] },
"south": { "texture": "#1", "uv": [ 6.0, 9.0, 10.0, 11.0 ] },
"west": { "texture": "#1", "uv": [ 5.0, 9.0, 6.0, 11.0 ] },
"up": { "texture": "#1", "uv": [ 1.0, 8.0, 5.0, 9.0 ] },
"down": { "texture": "#1", "uv": [ 5.0, 8.0, 9.0, 9.0 ] }
}
}
]
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/collation_folder" }
}
@@ -0,0 +1 @@
{ "parent": "rivencraft:block/link_modifier" }
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/sealed_notebook" }
}
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more