Compare commits

..
Author SHA1 Message Date
dnorberg be8440ddb5 Port collections, dropped books, return links and Writing Desk to 26.3
Build / build (push) Waiting to run
2026-11-05 22:04:24 -05:00
60 changed files with 3836 additions and 13 deletions

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ feature completion, and random structure selection. Nether/End noise-biome
materials now follow their native rules. The beta does not yet provide full
1.21.1 gameplay or optional-integration parity.
See the [26.3 beta guide](ports/mc263/README.md) and
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.98-beta.1.md).
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.106-beta.1.md).
Keep the builds and their saves separate, and back up worlds before updating.
## Getting started
Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+60
View File
@@ -10,6 +10,66 @@ tracks issues #97–#103 under parent #96.
## Version boundary
Local slice `1.0.1.106-beta.1` ports the Writing Desk's two-part block,
88-tab inventory, authoritative catalog sync, copying transactions and 1.21.1
GUI/model assets. Vial exchange reuses the existing shared helper; symbol copy
cost defaults to one. Modern Container removal handles content drops while
the block cleans up its partner. The renderer uses extracted item states.
Current input helpers also correct Notebook right-click/scrollbar behavior.
Page content is a derived view of the existing port components; no saved Page
schema is replaced. Progression awards and optional JEI integration remain
pending. Client appearance/input/catalog acceptance remains required.
Local slice `1.0.1.105-beta.1` ports category Symbol Portfolios and Sealed
Notebooks, reusing item textures/models and collection menu/import behavior.
Portfolio volumes split at 252 pages. Sealed items use 1.21.1's 16-page draw
and candidate exclusions, with Survival consumption, Creative retention and
full-inventory drop fallback. No recipes are invented; discovery/library loot
and JEI integration remain later work. Unported effects are still marked red.
Local slice `1.0.1.104-beta.1` ports 1.21.1's all-or-nothing collection import
to the current Binder menu: shift-click appends and cursor strip-click inserts
before a page. Occupied page order is preserved, gaps are compacted and the
empty collection is returned. Capacity/panel conflicts are checked before any
mutation. This connects the newly ported collections to actual book assembly;
the dedicated stored-collection input/UI remains later parity work.
Local follow-up `1.0.1.103-beta.1` corrects old GLFW key literals retained in
the Notebook screen. Escape always reaches the normal container close path,
including focused search, and page-movement arrows use 26.3 key helpers.
The player reported the search-focused GUI could not be escaped in .102;
client keyboard acceptance must be repeated with .103.
Local slice `1.0.1.102-beta.1` ports Notebook/Collation Folder storage, menu,
Creative entries, Notebook crafting and the existing sparse page editor.
The 1.21.1 capacities (252/27), Link Panel insertion rejection, held-item
locking and search keyboard handling are preserved. The GUI uses 26.3 input
events/extraction and the existing glyph-only item pipeline, while retaining
the original page atlas proportions. Discovery, sealed notebooks, portfolios,
Writing Desk and bulk Binder collection use remain unfinished.
Local slice `1.0.1.101-beta.1` ports dropped-book entities, their open-book
renderer/textures, decay/weather damage and persistent components. Handheld
travel detaches only the active book and leaves it at departure after success;
failed travel or a cancelled entity spawn restores it. Shared handling covers
both book types and hands and keeps Return Link issuance after departure-book
handling. The newer render-state-based pickup path replaces the old copy()
workaround. Client rendering/pickup remain part of in-game validation.
Local slice `1.0.1.100-beta.1` ports automatic Return Link policy from 1.21.1
for handheld Descriptive Books, default DISABLED. ON_CREATION uses a persistent
world-wide per-Age marker; ALWAYS issues on each successful arrival. Exact
departure position/facing is captured before teleportation and issuance occurs
after Disarm, with inventory-full fallback to a destination item drop. Ordinary
Linking Books, failures and operator travel do not issue books. Stand/portal
integration remains pending with those systems.
Local slice `1.0.1.99-beta.1` ports the dedicated non-collectible arrival
platform block from 1.21.1, reusing its opaque crystal model and strength 0.3.
Fallback landings use it instead of collectible Crystal Blocks. No item,
loot, pick-block result, or material Page is available. Existing platforms
and the port's landing-search/footprint policy are unchanged.
Local slice `1.0.1.98-beta.1` ports the 1.21.1 Accelerated environment page:
stacking extra random block ticks in already-loaded player-nearby chunks,
eight-chunk/16,384-attempt default budgets, fair rotating section allocation,
+236 -3
View File
@@ -2,14 +2,15 @@
This is an **experimental beta port** for issues #97–#104, not gameplay parity
with the Minecraft 1.21.1 release. The 26.3 backend persists separate Ages and
their seeds. Books, the Book Binder, and the Ink Mixer work, and the Binder can
their seeds. Books, the Book Binder, Ink Mixer, Writing Desk and page collections
are ported, and the Binder can
author Standard, Amplified, Island, Flat, Void, or Cave terrain with supported
biome-distribution pages, selected material/vegetation/structure features, and
independent Age weather and lighting, multiple authored or seeded celestial
bodies and eclipses, biome/material completion, and random features/structures.
Instability, crystal portals, discovery/stations, writing recovery and optional
Instability, crystal portals, remaining discovery/stations, writing recovery and optional
integration parity remain unfinished. The latest published beta is
[1.0.1.98-beta.1](RELEASE_NOTES_1.0.1.98-beta.1.md).
[1.0.1.106-beta.1](RELEASE_NOTES_1.0.1.106-beta.1.md).
Use a test world and keep a backup; do not
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
@@ -1413,6 +1414,238 @@ overflow/disabled budget arithmetic, unload/reload, reseed and full restart.
No player was connected for the server checks, so live growth remains an
in-game acceptance check. The test server was stopped and its world removed.
### Non-collectible arrival platforms
The local `1.0.1.99-beta.1` slice ports 1.21.1's dedicated `cloud_platform`
block for fallback Age landings. It reuses the existing opaque Crystal Block
texture, has glass-like breaking strength (0.3), and produces no loot or
pick-block item. It has no item, recipe, Creative entry or generated material
Page. Ordinary Crystal Blocks retain their normal behavior.
New fallback platforms use this block. Existing platforms made from ordinary
Crystal Blocks are not rewritten. This slice changes the fallback material;
the port's existing landing search and 3-by-3 fallback footprint remain.
Test a new Void Age: arrive on the platform, check its appearance and footing,
then break a block in Survival and confirm there is no drop. Creative
pick-block must not supply an item. Compare a normal Crystal Block, and revisit
the platform after restart. Operator fixtures
`/rivencraft_port age create_platform <name> <seed>` and
`/rivencraft_port age platform_probe <name>` validate a disposable Void
platform's properties and saved blocks.
The build and disposable dedicated-server checks passed for actual Void
arrival generation, collision/opacity, strength, loot, pick-block exclusion,
material-page exclusion, unload/reload and full restart. Shared model assets
were verified in the JAR. The test server was stopped and its world removed;
client appearance and Survival breaking remain the in-game check.
### Configurable automatic Return Links
The local `1.0.1.100-beta.1` slice ports the 1.21.1 automatic Return Link
policy for successful handheld Descriptive Book arrivals. In
`config/rivencraft-local.toml`, set `[descriptive_books] return_link_mode` to
`DISABLED` (the default), `ON_CREATION`, or `ALWAYS`. ON_CREATION issues one
Return Link when the Age is first reached with that mode enabled, shared
across players in the world. ALWAYS issues one on each successful arrival.
Disabled/Always arrivals do not consume the ON_CREATION marker, matching
1.21.1. The marker persists across reload, reseeding and restart.
Return Links retain the exact departure dimension, position, yaw and pitch.
They use ordinary Linking Book rules and have no automatic Link Panel
modifiers. They are issued after Disarm and dropped at the destination if
inventory space is unavailable. Failed travel, binding a book, ordinary
Linking Book travel and operator teleport probes do not issue Return Links.
Stands/portals remain pending and will need the same successful-travel hook.
For the in-game check, confirm the default gives no Return Link. Then enable
ON_CREATION and visit a fresh Age twice: only the first arrival should grant
one. Use that book to return and check position/facing; restart and revisit
to confirm no duplicate. In ALWAYS, each arrival should grant a book. Test
a Disarm Descriptive Book and a full inventory as well. Restore DISABLED
after testing if automatic Return Links are unwanted.
The build and disposable-server fixtures passed mode decisions, book target
components, visit-record codec round trips, world persistence through
unload/reload, reseeding and restart, and config loading with DISABLED and
ON_CREATION. No player joined those fixtures; actual travel, Disarm and
inventory-full delivery remain the in-game acceptance check. The server was
stopped and its test world removed.
### Handheld books left at departure
The local `1.0.1.101-beta.1` slice ports the 1.21.1 dropped-book entity and
handheld travel ownership. Successful handheld Linking and Descriptive Book
travel removes the active book from its hand and leaves it at departure as
an open-book entity. The existing Linking/Descriptive entity textures and
skyward-facing model orientation are reused with 26.3's render-state API.
The other hand and ordinary inventory remain unchanged unless Disarm applies.
Failed travel restores the book. A rejected dropped-entity spawn also restores
it instead of consuming it. Return Links are issued after the active book is
left behind. Dropped books have a one-second pickup delay, no ordinary five-
minute expiry, slow damage every 10,000 loaded ticks, and water/rain damage,
matching 1.21.1. Book components and the decay counter persist with the entity.
26.3 pickup particles use the extracted entity render state; 1.21.1's custom
`ItemEntity.copy()` workaround is not needed on this target.
Test travel from both hands with each book type, return to recover the open
book, verify pages face upward, and check pickup animation and restart.
Attempt a blocked/invalid link and confirm the book stays in hand. With
Disarm, the active book should use its model while other items drop normally.
With automatic Return Links enabled, the new Return Link should arrive with
the player. Test rain/water damage with disposable books only.
Build and disposable dedicated-server checks passed both hands, failed travel,
exception recovery, cancelled entity spawning, successful hand removal, saved
book components/lifetime, and a full restart with both book entities restored.
The fixture uses a fake player and does not prove client rendering or actual
cross-dimension travel. Entity loading is asynchronous: querying in the same
command batch as chunk loading can briefly see zero books. The server was
stopped and its world removed after validation. Operator fixtures are
`age create_dropped_fixture <name> <seed>` and `age dropped_probe <name>`.
### Notebook and Collation Folder
The local `1.0.1.102-beta.1` slice adapts the 1.21.1 Notebook and Collation
Folder, their server menu, persistent page component and existing page editor.
Notebook capacity is 252 positions; Folder capacity is 27. Sparse positions,
dark empty pages through the last occupied position, search, scrolling,
single-page movement, shift transfers and the held-item lock are retained.
Search consumes inventory keys while focused. Large pages keep the native
30-by-40 atlas proportions and use the existing glyph-only item renderer.
Unsupported symbol pages receive the port's red tint and retain their items.
Both collections appear in Creative. Notebook crafting reuses the 1.21.1
leather/string pattern. The Folder has no recipe in 1.21.1 and receives none
here; discovery/library loot is still pending. Link Panels cannot be inserted
through the collection menu. Unknown symbol pages remain stored and are
counted in the tooltip. Writing Desk integration, sealed notebooks, Symbol
Portfolios and bulk Binder interactions remain later work.
Test both collection types and hands. Insert and remove pages by normal and
shift clicks, place a page near the final position, move it with left/right
arrows after selection, scroll/search, and type E in the search field. Verify
Link Panels are rejected and the held collection cannot be moved/swapped.
Close/reopen, put the collection in a chest, restart and recover its contents.
Compare large glyphs and page outlines with the 1.21.1 editor.
Build and disposable-server checks passed both hands and capacities, sparse
positions, page movement, shift transfers through the actual menu input path,
Link Panel/non-Page rejection, held-item pickup locking, save/network codec
round trips, and all four collections stored in a chest across full restart.
The recipe and adapted recipe-unlock advancement loaded successfully. Shared
assets were checked in the JAR. No client joined these fixtures, so appearance,
mouse handling and search keys still need the in-game check. The test server
was stopped and every disposable test world removed.
The local `1.0.1.103-beta.1` follow-up fixes the Notebook/Folder keyboard port:
26.3 uses different key values from the old GLFW-based implementation. Escape
now delegates to normal container closing even with search focused, while
left/right page movement uses the current key helpers. Search still consumes
ordinary inventory/hotbar keys. Check Escape with search focused and while
holding a page on the cursor; closing should use normal carried-item recovery.
### Collection imports in the Book Binder
The local `1.0.1.104-beta.1` slice adapts 1.21.1's transactional collection
import. Shift-click a Notebook or Collation Folder in the Binder inventory
to append its pages. Carry one on the cursor and click the page strip to
insert its pages before that position; clicking the panel position inserts
at the beginning. Empty collection positions are skipped, preserving the
relative order of occupied pages. Later Binder pages shift to make room.
The import validates every page and total capacity before changing anything.
A full Binder, invalid insertion point, empty collection, or conflicting
Link Panel rejects the transaction without consuming pages. Successful
imports leave the empty collection on the cursor or in its inventory slot,
retaining its other components. Ordinary collection menus still reject Link
Panels; the Binder retains 1.21.1's handling for explicitly supplied collection
data containing a panel. Dedicated stored-collection input/UI remains unported.
Test shift-click append and cursor insertion for both collection types, using
distinct ordered pages and gaps. Check that the collection empties only after
success and that rejected imports leave both sides unchanged. Bind a book
with the imported pages, inspect its symbols, and restart with pages in the
Binder and collections in a chest to check persistence.
Build and disposable-server checks passed full-capacity and panel-conflict
rejection, invalid positions, sparse cursor insertion, shift append, empty-cover
retention, real Binder assembly and book page order. The empty collections and
finished book survived full restart in a chest. No client joined this fixture;
cursor/shift UI interaction remains the in-game check. The server was stopped
and its world removed. Include collection imports in the future JEI guide port.
### Symbol Portfolios and Sealed Notebooks
The local `1.0.1.105-beta.1` slice ports the remaining collection items from
1.21.1. The main Creative tab offers prefilled Symbol Portfolios for every
symbol-page category, including registered biome/material/fluid pages.
Categories exceeding 252 pages split into named volumes without truncation.
Portfolios use the Notebook editor and Binder collection import and can be
emptied/refilled like ordinary collections. No portfolio recipe is added.
A Sealed Notebook opens server-side into a Collation Folder with 16 random
pages. As in 1.21.1, the candidate pool excludes IDs under `block/` and
`biome/` and the corrupted category; repeated draws are allowed. This also
excludes biome-controller IDs under `biome/`, matching the existing code.
The pool is not restricted to already-ported effects, so unsupported pages
retain their red tint. Survival consumes one sealed item; Creative retains
it. The last item becomes the Folder in hand, otherwise the Folder goes into
inventory or drops if there is no space. Sealed Notebooks appear in Creative;
their discovery/library loot remains pending, and no crafting recipe is added.
Test portfolio names, volumes, contents, editor and Binder import. Open sealed
stacks from both hands in Survival, including with full inventory, and confirm
each Folder has 16 pages. Check Creative retains the sealed item. Put the
resulting items in a chest, restart and revisit.
Build and disposable-server checks passed the 252/253-page boundary, complete
base-registry category coverage/order, seeded draws and exclusions, both-hand
opening, Survival/Creative consumption, full-inventory delivery, and portfolio/
Folder persistence in a chest across restart. Shared models/textures were
verified in the JAR. The server was stopped and its world removed. No client
joined those fixtures; Creative presentation, editor and Binder interaction
remain the in-game check. Future JEI collection guides should cover these items.
### Writing Desk
The local `1.0.1.106-beta.1` slice adapts the 1.21.1 Writing Desk, its
two-block model, inventory, server catalog and existing GUI. It reuses the
Legacy desk/scrollbar/page atlases and current block textures. Selected pages
and white paper stacks render on the desk using the existing offsets.
There are 88 collection tabs, cycled four at a time. Each holds a loose Page
or a collection with its normal capacity. Search, AZ sorting, ALL view,
category filtering, page selection, movement and extraction retain the
1.21.1 menu rules. Link Panels cannot be stored or copied, and no Link Panel
creation recipe is added to the desk. Unknown pages remain recoverable but
cannot be copied while unresolved. Unsupported port effects remain red.
Supply paper and Ink Vials, select a known symbol, then take its virtual output
to copy it. Previewing consumes nothing; taking a copy consumes one paper and
one ink by default. `[writing] symbol_ink_cost` in `rivencraft-local.toml`
retains the 1.21.1 configurable cost. Vials fill a 16-unit meter and return
vanilla glass bottles. Refilling is event-driven and requires bottle space;
there is no refill server ticker. Originals remain in their collection.
The desk recipe and its recipe-unlock advancement are adapted to 26.3.
New Minecraft handles Container drops during block-entity removal, so the
old onRemove drop call is replaced by partner cleanup without duplicate
inventory drops. The GUI uses current input helpers, including Escape while
search is focused. The same mouse-button correction fixes Notebook right-click
and scrollbar dragging. Progression awards and JEI guides remain later work.
Test placement/orientation and opening from both halves, paper/selected-page
rendering and neighboring block visibility. Check the atlas layout and tab
numerals, cycling 0–87, searching/sorting/ALL, page dragging into inventory,
and copying with paper/ink. Confirm costs occur only on pickup, bottles block
refill when full, and removing bottle space allows refill. Restart with loaded
collections and a selected page. Break either half and check drops once.
Build and disposable-server checks passed copy preview/pickup costs,
inventory-full rejection, Link Panel rejection, sparse page movement/extraction,
88-tab reachability, all four facings and both halves' removal/drop counts.
Collections, active tab, ink, paper and display page survived full restart.
Recipe/advancement data loaded; shared models and atlases were verified in
the JAR. No client joined these fixtures: visuals, mouse and live catalog
updates remain the in-game check. The server was stopped and world removed.
From the repository root:
```sh
@@ -0,0 +1,85 @@
# Rivencraft 26.3 beta 1.0.1.106-beta.1
Collects the player-validated port slices .99–.106 since 1.0.1.98-beta.1.
This beta is not yet feature-complete with Minecraft 1.21.1.
## Books and travel
- Fallback Age platforms now use the dedicated non-collectible platform block
from 1.21.1: opaque crystal texture, glass-like breaking strength, no drops
or pick-block item. Existing ordinary Crystal Block platforms are unchanged.
- Ported automatic Return Links for successful handheld Descriptive Book
travel. `descriptive_books.return_link_mode` in `config/rivencraft-local.toml`
defaults to **DISABLED**. `ON_CREATION` issues one on the first arrival per
Age/world with that mode enabled; `ALWAYS` issues one on each arrival.
- Return Links preserve departure dimension, position and facing. They are
issued after Disarm and drop at the destination if inventory is full.
First-arrival records persist across reload, reseeding and restart.
- Successful handheld Linking and Descriptive Book travel now leaves the
active book at departure as a recoverable open-book entity. Both hands are
supported. Failed travel or canceled entity spawning restores the book.
- Dropped books reuse the existing textures and skyward-facing model. They
have a one-second pickup delay, no ordinary five-minute expiry, slow decay
and water/rain damage, matching 1.21.1. Their components and decay counter
persist through restart.
## Page collections and binding
- Ported Notebooks and Collation Folders with their existing editor and assets,
capacities of 252 and 27 positions, sparse page storage, search, scrolling,
page movement and inventory transfers. Link Panels are rejected by the editor.
- Notebook crafting uses the existing leather/string pattern. The held
collection is locked against ordinary menu movement. Escape closes the menu
with search focused; search still consumes inventory/hotbar shortcuts.
- Corrected the port's keyboard and mouse-button mappings, including arrow
movement, right-click handling and scrollbar dragging.
- The Book Binder accepts collection imports: shift-click to append pages or
click a carried collection on the strip to insert them. Empty positions are
skipped and occupied page order is retained. Imports validate all inputs and
capacity before committing; successful imports return the empty collection.
- Added category Symbol Portfolios to Creative, including registry-derived
biome, material and fluid pages. Large categories split into volumes of up
to 252 pages. Portfolios use the editor and Binder import flow.
- Sealed Notebooks open into Folders containing 16 random pages using the
1.21.1 pool/exclusions. Survival consumes one; Creative retains the item.
Full inventories receive a dropped Folder. No new recipes are invented for
Folders, Portfolios or Sealed Notebooks; discovery/library loot is pending.
## Writing Desk
- Ported the two-block Writing Desk, its existing model/textures, selected-page
and white paper-stack display, and Legacy GUI atlases.
- Ported 88 tabs cycled four at a time, category/search/sort/ALL views, collection
and loose-page storage, page movement/extraction, and server-owned catalog
synchronization. Link Panels cannot be stored or copied in the desk.
- Copy previews are virtual. Taking a copy consumes one paper and one ink by
default while retaining the original page. `writing.symbol_ink_cost` is
configurable in `rivencraft-local.toml`.
- Vials fill a 16-unit meter and return vanilla glass bottles. Refills are
event-driven and require bottle space. Full inventory prevents shift-copy
consumption. Unknown pages are retained but cannot be copied unresolved.
- Ported crafting and adapted its recipe-unlock advancement to 26.3.
Removing either desk half removes its partner and drops contents once.
- Further corrections to the desk model, focused on page/paper placement,
remain tracked for both versions in issue #113.
## Compatibility and validation
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
Dimensions and Bad Packets are not required. The 1.21.1 implementation is
unchanged. Use matching client/server builds; Age catalog protocol remains 10.
- Player in-game checks passed. Both build targets passed. Targeted disposable
server fixtures covered ownership recovery, collection/copy transactions,
save/network codecs, block removal, reload/reseed and restart persistence.
Servers were stopped and test worlds removed. This was not a full GameTest run.
- Full writing recovery, remaining discovery/stations, instability, crystal
portals, progression awards and optional integration parity remain unfinished.
JEI guides and discovery loot for the newly ported interactions are pending.
Unsupported effects remain marked red; Creative availability is not parity.
- Back up saves. New Native Cave profiles from .97 onward cannot be read by
earlier betas, and newly ported items/entities also require the newer build.
Keep 1.21.1 and 26.3 saves separate. Machine acceleration (#112) and Native
Cave default biome-pool policy (#111) remain separate planned work.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
+21
View File
@@ -203,6 +203,23 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/textures/gui/symbolcomponents.png'
include 'assets/rivencraft/models/item/page_link_panel.json'
include 'assets/rivencraft/textures/item/ink_vial.png'
include 'assets/rivencraft/textures/item/notebook.png'
include 'assets/rivencraft/textures/item/collation_folder.png'
include 'assets/rivencraft/models/item/notebook.json'
include 'assets/rivencraft/models/item/collation_folder.json'
include 'assets/rivencraft/textures/item/sealed_notebook.png'
include 'assets/rivencraft/textures/item/symbol_portfolio.png'
include 'assets/rivencraft/models/item/sealed_notebook.json'
include 'assets/rivencraft/models/item/symbol_portfolio.json'
include 'assets/rivencraft/textures/item/writing_desk.png'
include 'assets/rivencraft/textures/block/writing_desk.png'
include 'assets/rivencraft/textures/gui/writing_desk_legacy.png'
include 'assets/rivencraft/textures/gui/scrollbar.png'
include 'assets/rivencraft/models/item/writing_desk.json'
include 'assets/rivencraft/models/block/writing_desk.json'
include 'assets/rivencraft/models/block/writing_desk_extension.json'
include 'assets/rivencraft/blockstates/writing_desk.json'
include 'data/rivencraft/loot_table/blocks/writing_desk.json'
include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta'
include 'assets/rivencraft/models/item/ink_vial.json'
include 'assets/rivencraft/models/item/crystal_shard.json'
@@ -215,6 +232,8 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/textures/gui/notebook_pages.png'
include 'assets/rivencraft/textures/gui/bookui_pager.png'
include 'assets/rivencraft/textures/block/crystal_block.png'
include 'assets/rivencraft/textures/entity/linking_book.png'
include 'assets/rivencraft/textures/entity/descriptive_book.png'
include 'assets/rivencraft/textures/block/book_binder.png'
include 'assets/rivencraft/textures/block/book_binder_press.png'
include 'assets/rivencraft/textures/gui/book_binder_legacy.png'
@@ -233,6 +252,8 @@ tasks.named('processResources', ProcessResources).configure {
include 'data/rivencraft/loot_table/blocks/book_binder.json'
include 'assets/rivencraft/models/block/crystal_block.json'
include 'assets/rivencraft/blockstates/crystal_block.json'
include 'assets/rivencraft/models/block/cloud_platform.json'
include 'assets/rivencraft/blockstates/cloud_platform.json'
include 'assets/rivencraft/blockstates/budding_crystal.json'
include 'assets/rivencraft/blockstates/small_crystal_bud.json'
include 'assets/rivencraft/blockstates/medium_crystal_bud.json'
+1 -1
View File
@@ -7,4 +7,4 @@ neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.98-beta.1
mod_version=1.0.1.106-beta.1
@@ -2,14 +2,26 @@ package net.dnorberg.rivencraft;
import net.neoforged.neoforge.common.ModConfigSpec;
/** Server-side limits shared with the 1.21.1 Accelerated implementation. */
/** Server-owned gameplay policies adapted from 1.21.1. */
public final class PortConfig {
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
public static final ModConfigSpec SPEC;
public static final ModConfigSpec.IntValue SYMBOL_INK_COST;
public static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
public static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS;
public static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS;
static {
var builder = new ModConfigSpec.Builder();
builder.push("descriptive_books");
RETURN_LINK_MODE = builder.comment("Controls automatic Return Links for successful Descriptive Book travel.",
"ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.")
.defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
builder.pop();
builder.push("writing");
SYMBOL_INK_COST = builder.comment("Ink units consumed for one Writing Desk symbol copy.")
.defineInRange("symbol_ink_cost", 1, 1, 16);
builder.pop();
builder.push("accelerated_age");
ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded player-nearby chunks given extra random ticks per Age tick.")
.defineInRange("max_loaded_chunks", 8, 1, 64);
@@ -57,6 +57,15 @@ public final class Rivencraft {
public static final Logger LOGGER = LogUtils.getLogger();
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID);
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
private static final DeferredRegister<net.minecraft.world.entity.EntityType<?>> ENTITIES =
DeferredRegister.create(Registries.ENTITY_TYPE, MOD_ID);
public static final Supplier<net.minecraft.world.entity.EntityType<net.dnorberg.rivencraft.entity.DroppedBookEntity>>
DROPPED_BOOK = ENTITIES.register("dropped_book", () -> net.minecraft.world.entity.EntityType.Builder
.<net.dnorberg.rivencraft.entity.DroppedBookEntity>of(
net.dnorberg.rivencraft.entity.DroppedBookEntity::new, net.minecraft.world.entity.MobCategory.MISC)
.sized(0.65F, 0.4F).clientTrackingRange(10).updateInterval(20)
.build(net.minecraft.resources.ResourceKey.create(Registries.ENTITY_TYPE,
Identifier.fromNamespaceAndPath(MOD_ID, "dropped_book"))));
private static final DeferredRegister<SoundEvent> SOUNDS =
DeferredRegister.create(Registries.SOUND_EVENT, MOD_ID);
public static final Supplier<SoundEvent> LINK = sound("linking.link");
@@ -82,6 +91,10 @@ public final class Rivencraft {
public static final Supplier<DataComponentType<Identifier>> AGE_BOOK_ID = COMPONENTS.registerComponentType(
"age_book_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
public static final Supplier<DataComponentType<net.dnorberg.rivencraft.writing.NotebookContents>> NOTEBOOK_CONTENTS =
COMPONENTS.registerComponentType("notebook_contents", builder -> builder
.persistent(net.dnorberg.rivencraft.writing.NotebookContents.CODEC)
.networkSynchronized(net.dnorberg.rivencraft.writing.NotebookContents.STREAM_CODEC));
public static final Supplier<DataComponentType<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
@@ -110,6 +123,10 @@ public final class Rivencraft {
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
public static final DeferredBlock<net.dnorberg.rivencraft.block.CloudPlatformBlock> CLOUD_PLATFORM =
BLOCKS.registerBlock("cloud_platform", net.dnorberg.rivencraft.block.CloudPlatformBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)
.mapColor(MapColor.COLOR_BLUE).strength(0.3F).noLootTable());
public static final DeferredBlock<StarFissureBlock> STAR_FISSURE =
BLOCKS.registerBlock("star_fissure", StarFissureBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6));
@@ -134,6 +151,25 @@ public final class Rivencraft {
new BlockEntityType<>(InkMixerBlockEntity::new, INK_MIXER.get()));
public static final Supplier<MenuType<InkMixerMenu>> INK_MIXER_MENU =
MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new));
public static final DeferredBlock<net.dnorberg.rivencraft.block.WritingDeskBlock> WRITING_DESK =
BLOCKS.registerBlock("writing_desk", net.dnorberg.rivencraft.block.WritingDeskBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredItem<BlockItem> WRITING_DESK_ITEM = ITEMS.registerSimpleBlockItem(WRITING_DESK);
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity>> WRITING_DESK_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("writing_desk", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity::new, WRITING_DESK.get()));
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.WritingDeskMenu>> WRITING_DESK_MENU =
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.WritingDeskMenu::new));
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.NotebookMenu>> NOTEBOOK_MENU =
MENUS.register("notebook", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.NotebookMenu::new));
public static final DeferredItem<net.dnorberg.rivencraft.item.NotebookItem> NOTEBOOK =
ITEMS.registerItem("notebook", net.dnorberg.rivencraft.item.NotebookItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.CollationFolderItem> COLLATION_FOLDER =
ITEMS.registerItem("collation_folder", net.dnorberg.rivencraft.item.CollationFolderItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.SymbolPortfolioItem> SYMBOL_PORTFOLIO =
ITEMS.registerItem("symbol_portfolio", net.dnorberg.rivencraft.item.SymbolPortfolioItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.SealedNotebookItem> SEALED_NOTEBOOK =
ITEMS.registerItem("sealed_notebook", net.dnorberg.rivencraft.item.SealedNotebookItem::new, properties -> properties.stacksTo(16));
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK);
public static final DeferredBlock<BuddingCrystalBlock> BUDDING_CRYSTAL = BLOCKS.registerBlock(
"budding_crystal", BuddingCrystalBlock::new,
@@ -171,6 +207,7 @@ public final class Rivencraft {
BLOCKS.register(modEventBus);
SOUNDS.register(modEventBus);
ITEMS.register(modEventBus);
ENTITIES.register(modEventBus);
BLOCK_ENTITY_TYPES.register(modEventBus);
MENUS.register(modEventBus);
COMPONENTS.register(modEventBus);
@@ -69,6 +69,117 @@ public final class PortAgeCommands {
return 1;
})))
.then(Commands.literal("age")
.then(Commands.literal("create_writing_desk_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortWritingDeskProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Writing Desk transaction/removal checks passed"), false);
return 1;
}))))
.then(Commands.literal("writing_desk_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortWritingDeskProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Writing Desk persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_collection_extras_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortCollectionExtrasProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Portfolio/Sealed Notebook checks passed"), false);
return 1;
}))))
.then(Commands.literal("collection_extras_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortCollectionExtrasProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Portfolio/Sealed Notebook persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_binder_collection_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortBinderCollectionProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Binder collection transaction/assembly checks passed"), false);
return 1;
}))))
.then(Commands.literal("binder_collection_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortBinderCollectionProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Binder collection persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_notebook_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortNotebookProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Notebook menu/codec checks passed"), false);
return 1;
}))))
.then(Commands.literal("notebook_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortNotebookProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Notebook chest persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_dropped_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortDroppedBookProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Dropped book ownership/codec checks passed"), false);
return 1;
}))))
.then(Commands.literal("dropped_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
level.getChunk(0, 0);
PortDroppedBookProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Dropped book persistence checks passed"), false);
return 1;
})))
.then(Commands.literal("create_return_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(PortAgeCommands::createReturnFixture))))
.then(Commands.literal("return_link_status")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::returnLinkStatus)))
.then(Commands.literal("create_platform")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(PortAgeCommands::createPlatform))))
.then(Commands.literal("platform_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probePlatform)))
.then(Commands.literal("create_accelerated")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
@@ -2146,6 +2257,85 @@ public final class PortAgeCommands {
return 1;
}
private static int createReturnFixture(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
var local = new PortAgeVisits(java.util.List.of());
var disabled = net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.DISABLED;
var always = net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ALWAYS;
var once = net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ON_CREATION;
if (PortReturnLinks.shouldIssue(disabled, local, id) || local.hasVisited(id)
|| !PortReturnLinks.shouldIssue(always, local, id)
|| !PortReturnLinks.shouldIssue(always, local, id) || local.hasVisited(id)
|| !PortReturnLinks.shouldIssue(once, local, id)
|| PortReturnLinks.shouldIssue(once, local, id))
throw new IllegalStateException("Return Link mode regression");
var encoded = PortAgeVisits.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, local).getOrThrow();
var decoded = PortAgeVisits.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow();
if (!decoded.hasVisited(id) || PortReturnLinks.shouldIssue(once, decoded, id))
throw new IllegalStateException("Return Link visit codec regression");
var target = new LinkBookTarget(Identifier.fromNamespaceAndPath("minecraft", "overworld"),
12.25, 80.5, -42.75, 135.0F, -20.0F);
var book = PortReturnLinks.createBook(target);
if (!book.is(Rivencraft.LINKING_BOOK.get())
|| !target.equals(book.get(Rivencraft.LINK_BOOK_TARGET.get()))
|| !book.getHoverName().getString().equals("Return Link")
|| book.has(Rivencraft.LINK_PROPERTIES.get()))
throw new IllegalStateException("Return Link book component regression");
var server = context.getSource().getServer();
RuntimeAgeBackend.create(server, id, LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
var visits = server.overworld().getDataStorage().computeIfAbsent(PortAgeVisits.TYPE);
if (!PortReturnLinks.shouldIssue(once, visits, id) || PortReturnLinks.shouldIssue(once, visits, id))
throw new IllegalStateException("Return Link world marker regression");
server.overworld().getDataStorage().saveAndJoin();
return returnLinkStatus(context);
}
private static int returnLinkStatus(CommandContext<CommandSourceStack> context) {
var visits = context.getSource().getServer().overworld().getDataStorage().computeIfAbsent(PortAgeVisits.TYPE);
context.getSource().sendSuccess(() -> Component.literal(ageId(context) + " Return Link mode="
+ net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.get()
+ "; ON_CREATION issued=" + visits.hasVisited(ageId(context))), false);
return 1;
}
private static int createPlatform(CommandContext<CommandSourceStack> context) {
ServerLevel level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
var landing = PortAgeTravel.findLanding(level);
if (!new net.minecraft.core.BlockPos(0, 64, 0).equals(landing))
throw new IllegalStateException("Unexpected Void landing: " + landing);
return probePlatform(context);
}
/** Inspect the disposable Void fixture without regenerating or repairing its platform. */
private static int probePlatform(CommandContext<CommandSourceStack> context) {
ServerLevel level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + ageId(context)));
return 0;
}
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) {
var pos = new net.minecraft.core.BlockPos(x, 63, z);
var state = level.getBlockState(pos);
if (!state.is(Rivencraft.CLOUD_PLATFORM.get())
|| state.getDestroySpeed(level, pos) != 0.3F
|| !state.isSolidRender() || !state.isCollisionShapeFullBlock(level, pos)
|| !state.getCloneItemStack(level, pos, false).isEmpty()
|| !net.minecraft.world.level.block.Block.getDrops(state, level, pos, null).isEmpty())
throw new IllegalStateException("Platform properties mismatch at " + pos);
}
Identifier id = Identifier.fromNamespaceAndPath("rivencraft", "cloud_platform");
if (net.dnorberg.rivencraft.writing.PortRegistryPages.solidMaterialIds().contains(id)
|| net.dnorberg.rivencraft.writing.PortRegistryPages.find(
net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId(id)) != null
|| !Rivencraft.CLOUD_PLATFORM.get().asItem().equals(net.minecraft.world.item.Items.AIR))
throw new IllegalStateException("Platform is obtainable as an item or material Page");
context.getSource().sendSuccess(() -> Component.literal(ageId(context)
+ " platform passed: solid/opaque, strength 0.3, no loot/pick item/material Page"), false);
return 1;
}
private static int createAccelerated(CommandContext<CommandSourceStack> context) {
int count = IntegerArgumentType.getInteger(context, "pages");
var pages = new java.util.ArrayList<Identifier>();
@@ -37,7 +37,7 @@ public final class PortAgeTravel {
return moved ? null : "Teleportation failed; remaining in source dimension";
}
private static BlockPos findLanding(ServerLevel target) {
static BlockPos findLanding(ServerLevel target) {
if (isCaveTerrain(target)) {
// The cave roof is not a valid arrival surface. Search down from
// Legacy's Y=64 start, then construct the normal safety platform
@@ -112,7 +112,7 @@ public final class PortAgeTravel {
}
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
target.setBlock(foot.offset(x, -1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
target.setBlock(foot.offset(x, -1, z), Rivencraft.CLOUD_PLATFORM.get().defaultBlockState(), 3);
}
}
target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3);
@@ -0,0 +1,27 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** World-wide record of Ages for which an ON_CREATION Return Link was issued. */
final class PortAgeVisits extends SavedData {
static final Codec<PortAgeVisits> CODEC = Identifier.CODEC.listOf().fieldOf("ages").codec()
.xmap(PortAgeVisits::new, visits -> visits.ages.stream().sorted().toList());
static final SavedDataType<PortAgeVisits> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "age_visits"),
() -> new PortAgeVisits(List.of()), CODEC);
private final Set<Identifier> ages;
PortAgeVisits(List<Identifier> ages) { this.ages = new HashSet<>(ages); }
boolean hasVisited(Identifier age) { return ages.contains(age); }
boolean markFirstVisit(Identifier age) {
if (!ages.add(age)) return false;
setDirty();
return true;
}
}
@@ -0,0 +1,99 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
/** Disposable fixture for atomic collection imports and real Binder assembly. */
final class PortBinderCollectionProbe {
private static final BlockPos BINDER = new BlockPos(0, 64, 0);
private static final BlockPos CHEST = new BlockPos(1, 64, 0);
private static final List<Identifier> EXPECTED = List.of(id("weather/off"), id("lighting/bright"),
id("terrain/flat"), id("biome/single"));
private PortBinderCollectionProbe() {}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static ItemStack page(String path) { return PageItem.createSymbolPage(id(path)); }
private static ItemStack collection(boolean folder, List<ItemStack> pages) {
ItemStack result = new ItemStack(folder ? Rivencraft.COLLATION_FOLDER.get() : Rivencraft.NOTEBOOK.get());
result.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
return result;
}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(BINDER, Rivencraft.BOOK_BINDER.get().defaultBlockState(), 3);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
var binder = (PortBookBinderBlockEntity) level.getBlockEntity(BINDER);
var chest = (Container) level.getBlockEntity(CHEST);
ItemStack folder = collection(true, List.of(page("weather/off"), ItemStack.EMPTY, page("lighting/bright")));
ItemStack before = folder.copy();
for (int i = 0; i < PortBookBinderBlockEntity.PAGE_COUNT; i++)
binder.setItem(PortBookBinderBlockEntity.FIRST_PAGE + i, page("terrain/flat"));
if (!binder.importCollection(folder, 0).isEmpty() || binder.nextPageIndex() != 252
|| !ItemStack.isSameItemSameComponents(before, folder)
|| binder.symbolPages().stream().anyMatch(symbol -> !symbol.equals(id("terrain/flat"))))
throw new IllegalStateException("Full Binder import was not atomic");
binder.clearSymbolPages();
binder.setItem(PortBookBinderBlockEntity.PANEL, PageItem.createLinkPanelPage());
if (!binder.importCollection(collection(true, List.of(page("weather/off"), PageItem.createLinkPanelPage())), 0).isEmpty()
|| binder.hasSymbolPages() || binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
throw new IllegalStateException("Conflicting Link Panel import was not atomic");
binder.setItem(PortBookBinderBlockEntity.PANEL, ItemStack.EMPTY);
if (!binder.importCollection(folder, -1).isEmpty() || !binder.importCollection(folder, 1).isEmpty())
throw new IllegalStateException("Invalid collection insertion point accepted");
binder.insertPage(0, page("terrain/flat"));
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("5225a1ed-5ead-44b5-a63c-c78e7dca86f5"), "RivenBinderProbe"));
player.setPos(0.5, 64, 2.5);
var menu = new PortBookBinderMenu(1, player.getInventory(), binder);
try {
menu.setCarried(folder);
if (!menu.clickMenuButton(player, PortBookBinderMenu.STRIP_CLICK_BASE + 1)
|| menu.getCarried().get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0
|| !binder.symbolPages().equals(EXPECTED.subList(0, 3)))
throw new IllegalStateException("Cursor collection order/empty-cover regression");
chest.setItem(0, menu.getCarried().copy());
menu.setCarried(ItemStack.EMPTY);
player.getInventory().setItem(9, collection(false, List.of(page("biome/single"))));
menu.quickMoveStack(player, PortBookBinderBlockEntity.SIZE);
ItemStack emptied = player.getInventory().getItem(9);
if (!emptied.is(Rivencraft.NOTEBOOK.get())
|| emptied.get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0
|| !binder.symbolPages().equals(EXPECTED))
throw new IllegalStateException("Shift collection import regression");
chest.setItem(1, emptied.copy());
binder.setItem(PortBookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(PortBookBinderBlockEntity.PANEL, PageItem.createLinkPanelPage());
menu.assemble(player, "Collection Fixture104");
ItemStack book = menu.getCarried();
if (!book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) || !EXPECTED.equals(book.get(Rivencraft.BOOK_SYMBOLS.get()))
|| !book.has(Rivencraft.AGE_BOOK_ID.get()) || binder.hasSymbolPages()
|| !binder.getItem(PortBookBinderBlockEntity.COVER).isEmpty()
|| !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
throw new IllegalStateException("Collection-backed Binder assembly regression");
chest.setItem(2, book.copy());
menu.setCarried(ItemStack.EMPTY);
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
if (!chest.getItem(0).is(Rivencraft.COLLATION_FOLDER.get()) || !chest.getItem(1).is(Rivencraft.NOTEBOOK.get()))
throw new IllegalStateException("Empty collection covers were not preserved");
for (int i = 0; i < 2; i++) if (chest.getItem(i).get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0)
throw new IllegalStateException("Imported pages remained in a collection");
if (!EXPECTED.equals(chest.getItem(2).get(Rivencraft.BOOK_SYMBOLS.get())))
throw new IllegalStateException("Authored book page order was not preserved");
}
}
@@ -0,0 +1,109 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.SymbolPortfolioItem;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
/** Disposable-world boundary, booster-use and persistence checks. */
final class PortCollectionExtrasProbe {
private static final BlockPos CHEST = new BlockPos(0, 64, 0);
private PortCollectionExtrasProbe() {}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static NotebookContents contents(ItemStack stack) { return stack.get(Rivencraft.NOTEBOOK_CONTENTS.get()); }
private static List<Identifier> symbols(ItemStack collection) {
return contents(collection).pages().stream().map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList();
}
private static void folder(ItemStack stack) {
if (!stack.is(Rivencraft.COLLATION_FOLDER.get()) || contents(stack).pageCount() != 16
|| symbols(stack).stream().anyMatch(id -> !PortSymbolPages.sealedCandidateIds().contains(id)))
throw new IllegalStateException("Sealed Notebook page pool/count regression");
}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
var chest = (Container) level.getBlockEntity(CHEST);
var pages = new java.util.ArrayList<ItemStack>();
for (int i = 0; i < 253; i++) pages.add(PageItem.createSymbolPage(id(i % 2 == 0 ? "terrain/flat" : "weather/off")));
var volumes = SymbolPortfolioItem.createVolumes(id("feature"), pages);
if (volumes.size() != 2 || contents(volumes.get(0)).pageCount() != 252
|| contents(volumes.get(1)).pageCount() != 1
|| !volumes.stream().flatMap(volume -> symbols(volume).stream()).toList()
.equals(pages.stream().map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList())
|| !SymbolPortfolioItem.createVolumes(id("feature"), List.of()).isEmpty())
throw new IllegalStateException("Portfolio volume boundary/order regression");
chest.setItem(0, volumes.get(0));
chest.setItem(1, volumes.get(1));
var actual = net.dnorberg.rivencraft.registry.ModCreativeTabs.creativeSymbolPortfolios(level.registryAccess());
var allPages = PortSymbolPages.categories().stream().flatMap(category -> PortSymbolPages.pages(category, level.registryAccess()).stream())
.map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList();
if (!actual.stream().flatMap(volume -> symbols(volume).stream()).toList().equals(allPages))
throw new IllegalStateException("Creative portfolios omit/duplicate/reorder pages");
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("790b69dd-b62b-45be-9017-caf16515169a"), "RivenExtrasProbe"));
player.setPos(0.5, 64, 2.5);
try {
player.getAbilities().instabuild = false;
player.getRandom().setSeed(105);
var first = net.dnorberg.rivencraft.item.SealedNotebookItem.open(player);
player.getRandom().setSeed(105);
var repeat = net.dnorberg.rivencraft.item.SealedNotebookItem.open(player);
folder(first);
if (!symbols(first).equals(symbols(repeat))) throw new IllegalStateException("Booster seeded selection changed");
chest.setItem(2, first);
for (var hand : InteractionHand.values()) {
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
player.setItemInHand(hand, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get()));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, hand);
folder(player.getItemInHand(hand));
}
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get(), 2));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
if (player.getMainHandItem().getCount() != 1) throw new IllegalStateException("Booster stack consumption regression");
player.getAbilities().instabuild = true;
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
if (!player.getMainHandItem().is(Rivencraft.SEALED_NOTEBOOK.get()) || player.getMainHandItem().getCount() != 1)
throw new IllegalStateException("Creative booster consumed its input");
player.getAbilities().instabuild = false;
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get(), 2));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
var dropped = level.getEntitiesOfClass(ItemEntity.class, new AABB(-3, 60, -3, 4, 70, 6),
entity -> entity.getItem().is(Rivencraft.COLLATION_FOLDER.get()));
if (dropped.size() != 1 || player.getMainHandItem().getCount() != 1)
throw new IllegalStateException("Full-inventory booster delivery regression");
folder(dropped.getFirst().getItem());
dropped.getFirst().discard();
} finally {
player.getAbilities().instabuild = false;
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
}
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
if (!chest.getItem(0).is(Rivencraft.SYMBOL_PORTFOLIO.get()) || contents(chest.getItem(0)).pageCount() != 252
|| !chest.getItem(1).is(Rivencraft.SYMBOL_PORTFOLIO.get()) || contents(chest.getItem(1)).pageCount() != 1)
throw new IllegalStateException("Portfolio persistence regression");
folder(chest.getItem(2));
}
}
@@ -0,0 +1,81 @@
package net.dnorberg.rivencraft.age;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.PortHandheldTravel;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.world.level.storage.TagValueOutput;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
/** Disposable-world checks for book ownership and the new entity save format. */
final class PortDroppedBookProbe {
private PortDroppedBookProbe() {}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
var player = FakePlayerFactory.get(level, new com.mojang.authlib.GameProfile(
UUID.fromString("4db5c544-0a0c-421d-a128-c520bda64642"), "RivenBookProbe"));
player.setPos(0.5, 64, 0.5);
try {
for (var hand : InteractionHand.values()) {
ItemStack book = new ItemStack(hand == InteractionHand.MAIN_HAND
? Rivencraft.LINKING_BOOK.get() : Rivencraft.DESCRIPTIVE_BOOK.get());
book.set(DataComponents.CUSTOM_NAME, Component.literal("Dropped fixture " + hand));
book.set(Rivencraft.LINK_BOOK_TARGET.get(), new LinkBookTarget(
Identifier.fromNamespaceAndPath("minecraft", "overworld"), 12.25, 80.5, -42.75, 135, -20));
player.setItemInHand(hand, book);
String failure = PortHandheldTravel.travel(player, hand, book, () -> "expected failure");
if (!"expected failure".equals(failure) || player.getItemInHand(hand) != book)
throw new IllegalStateException("Failed travel consumed the book");
try {
PortHandheldTravel.travel(player, hand, book, () -> { throw new IllegalStateException("fixture"); });
throw new AssertionError("Fixture exception was swallowed");
} catch (IllegalStateException expected) {
if (!"fixture".equals(expected.getMessage()) || player.getItemInHand(hand) != book) throw expected;
}
java.util.function.Consumer<net.neoforged.neoforge.event.entity.EntityJoinLevelEvent> cancel = event -> {
if (event.getLevel() == level && event.getEntity() instanceof DroppedBookEntity) event.setCanceled(true);
};
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(cancel);
try {
failure = PortHandheldTravel.travel(player, hand, book, () -> null);
if (failure != null || player.getItemInHand(hand) != book)
throw new IllegalStateException("Rejected dropped entity consumed the book");
} finally { net.neoforged.neoforge.common.NeoForge.EVENT_BUS.unregister(cancel); }
failure = PortHandheldTravel.travel(player, hand, book, () -> null);
if (failure != null || !player.getItemInHand(hand).isEmpty())
throw new IllegalStateException("Successful travel retained the active book");
}
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
var books = level.getEntitiesOfClass(DroppedBookEntity.class, new AABB(-2, 60, -2, 3, 68, 3));
if (books.size() != 2) throw new IllegalStateException("Expected two dropped books, found " + books.size());
for (var entity : books) {
var output = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
entity.saveWithoutId(output);
var copy = new DroppedBookEntity(Rivencraft.DROPPED_BOOK.get(), level);
copy.load(TagValueInput.create(ProblemReporter.DISCARDING, level.registryAccess(), output.buildResult()));
if (!ItemStack.isSameItemSameComponents(copy.getItem(), entity.getItem())
|| copy.getAge() != -32768 || copy.getItem().getCount() != 1)
throw new IllegalStateException("Dropped book persistence regression");
var copied = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
copy.saveWithoutId(copied);
if (copied.buildResult().getIntOr("BookDecayTicks", -1)
!= output.buildResult().getIntOr("BookDecayTicks", -2))
throw new IllegalStateException("Dropped book decay counter was not preserved");
}
}
}
@@ -0,0 +1,94 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
/** Disposable-world menu, ownership, codec and chest-persistence fixtures. */
final class PortNotebookProbe {
private static final BlockPos CHEST = new BlockPos(0, 64, 0);
private PortNotebookProbe() {}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
Container chest = (Container) level.getBlockEntity(CHEST);
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("1043b9f3-f348-4a73-b585-e0de13fd5c27"), "RivenNotebookProbe"));
int output = 0;
try {
for (boolean folder : List.of(false, true)) for (var hand : InteractionHand.values()) {
player.getInventory().clearContent();
player.getInventory().setSelectedSlot(0);
ItemStack collection = new ItemStack(folder ? Rivencraft.COLLATION_FOLDER.get() : Rivencraft.NOTEBOOK.get());
player.setItemInHand(hand, collection);
var menu = new NotebookMenu(1, player.getInventory(), hand);
int capacity = CollationFolderItem.capacity(collection);
if (!menu.stillValid(player) || menu.pageCapacity() != capacity
|| menu.getSlot(0).mayPlace(PageItem.createLinkPanelPage())
|| menu.getSlot(0).mayPlace(new ItemStack(net.minecraft.world.item.Items.DIAMOND)))
throw new IllegalStateException("Notebook capacity/page acceptance regression");
ItemStack page = PageItem.createSymbolPage(Identifier.fromNamespaceAndPath("rivencraft", "terrain/flat"));
player.getInventory().setItem(9, page.copyWithCount(3));
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS, 0, ContainerInput.QUICK_MOVE, player);
if (!player.getInventory().getItem(9).isEmpty() || contents(collection).pageCount() != 3)
throw new IllegalStateException("Notebook shift insertion regression");
for (int i = 0; i < 3; i++) menu.clicked(i, 0, ContainerInput.QUICK_MOVE, player);
if (contents(collection).pageCount() != 0) throw new IllegalStateException("Notebook shift extraction regression");
menu.getSlot(capacity - 1).set(page.copy());
if (contents(collection).pageCount() != 1 || !contents(collection).pages().get(0).isEmpty()
|| contents(collection).pages().get(capacity - 1).isEmpty())
throw new IllegalStateException("Notebook sparse position regression");
if (!menu.clickMenuButton(player, NotebookMenu.MOVE_PREVIOUS_BASE + capacity - 1)
|| !contents(collection).pages().get(capacity - 1).isEmpty())
throw new IllegalStateException("Notebook page movement regression");
if (folder && menu.getSlot(capacity).mayPlace(page))
throw new IllegalStateException("Folder exceeded 27 positions");
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS + 27, 0, ContainerInput.PICKUP, player);
if (player.getItemInHand(hand) != collection || !menu.getCarried().isEmpty())
throw new IllegalStateException("Notebook active item was movable");
var ops = RegistryOps.create(com.mojang.serialization.JsonOps.INSTANCE, level.registryAccess());
var encoded = NotebookContents.CODEC.encodeStart(ops, contents(collection)).getOrThrow();
var decoded = NotebookContents.CODEC.parse(ops, encoded).getOrThrow();
var buffer = new RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), level.registryAccess());
try {
NotebookContents.STREAM_CODEC.encode(buffer, decoded);
decoded = NotebookContents.STREAM_CODEC.decode(buffer);
if (decoded.pageCount() != 1 || decoded.pages().get(capacity - 2).isEmpty())
throw new IllegalStateException("Notebook save/network codec regression");
} finally { buffer.release(); }
chest.setItem(output++, collection.copy());
player.setItemInHand(hand, ItemStack.EMPTY);
}
} finally { player.getInventory().clearContent(); }
verify(level);
}
private static NotebookContents contents(ItemStack stack) {
return stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
for (int i = 0; i < 4; i++) {
var collection = chest.getItem(i);
int capacity = i < 2 ? 252 : 27;
if (CollationFolderItem.capacity(collection) != capacity || contents(collection).pageCount() != 1
|| contents(collection).pages().get(capacity - 2).isEmpty())
throw new IllegalStateException("Notebook chest persistence regression: " + i);
}
}
}
@@ -0,0 +1,44 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.item.ItemStack;
/** Called only after successful Descriptive Book travel and its Disarm effect. */
public final class PortReturnLinks {
private PortReturnLinks() {}
public static void afterSuccessfulTravel(ServerPlayer player, Identifier age, LinkBookTarget departure) {
var mode = PortConfig.RETURN_LINK_MODE.get();
if (mode == PortConfig.ReturnLinkMode.DISABLED) return;
var storage = player.level().getServer().overworld().getDataStorage();
boolean issue = shouldIssue(mode, storage.computeIfAbsent(PortAgeVisits.TYPE), age);
if (!issue) return;
ItemStack book = createBook(departure);
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
// Persist the once-per-Age marker before the next arrival can issue another.
if (mode == PortConfig.ReturnLinkMode.ON_CREATION)
storage.saveAndJoin();
}
static boolean shouldIssue(PortConfig.ReturnLinkMode mode, PortAgeVisits visits, Identifier age) {
return switch (mode) {
case DISABLED -> false;
case ALWAYS -> true;
case ON_CREATION -> visits.markFirstVisit(age);
};
}
static ItemStack createBook(LinkBookTarget departure) {
ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get());
book.set(Rivencraft.LINK_BOOK_TARGET.get(), departure);
book.set(DataComponents.CUSTOM_NAME, Component.translatable("item.rivencraft.linking_book.return"));
return book;
}
}
@@ -0,0 +1,98 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Disposable server fixture for copying transactions, page moves and two-part removal. */
final class PortWritingDeskProbe {
private static final BlockPos POS = new BlockPos(0, 64, 0);
private PortWritingDeskProbe() {}
private static ItemStack page(String path) { return PageItem.createSymbolPage(Identifier.fromNamespaceAndPath("rivencraft", path)); }
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("f92ad9ee-1ff0-4fe2-bcd3-994d4c035933"), "RivenDeskProbe"));
player.setPos(0.5, 64, 2.5);
// New Minecraft drops Container block-entity contents during removal.
// Removing either half must remove its partner and drop contents once.
for (Direction facing : Direction.Plane.HORIZONTAL) for (boolean extension : List.of(false, true)) {
var state = Rivencraft.WRITING_DESK.get().defaultBlockState().setValue(WritingDeskBlock.FACING, facing);
level.setBlock(POS, state, 3);
Rivencraft.WRITING_DESK.get().setPlacedBy(level, POS, state, player, new ItemStack(Rivencraft.WRITING_DESK_ITEM.get()));
BlockPos other = POS.relative(facing.getClockWise());
if (!level.getBlockState(other).getValue(WritingDeskBlock.EXTENSION))
throw new IllegalStateException("Desk extension not placed");
var desk = (WritingDeskBlockEntity) level.getBlockEntity(POS);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 3));
level.destroyBlock(extension ? other : POS, true);
if (level.getBlockState(POS).is(Rivencraft.WRITING_DESK.get()) || level.getBlockState(other).is(Rivencraft.WRITING_DESK.get()))
throw new IllegalStateException("Desk partner survived removal");
var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,
new net.minecraft.world.phys.AABB(-3, 60, -3, 4, 69, 4));
int paper = drops.stream().filter(e -> e.getItem().is(Items.PAPER)).mapToInt(e -> e.getItem().getCount()).sum();
int desks = drops.stream().filter(e -> e.getItem().is(Rivencraft.WRITING_DESK_ITEM.get())).mapToInt(e -> e.getItem().getCount()).sum();
if (paper != 3 || desks != 1) throw new IllegalStateException("Desk removal duplicated/lost drops: " + paper + "/" + desks);
drops.forEach(net.minecraft.world.entity.Entity::discard);
}
var state = Rivencraft.WRITING_DESK.get().defaultBlockState();
level.setBlock(POS, state, 3);
Rivencraft.WRITING_DESK.get().setPlacedBy(level, POS, state, player, new ItemStack(Rivencraft.WRITING_DESK_ITEM.get()));
var desk = (WritingDeskBlockEntity) level.getBlockEntity(POS);
try {
if (desk.canPlaceItem(0, PageItem.createLinkPanelPage())) throw new IllegalStateException("Desk accepted Link Panel");
desk.setItem(0, page("terrain/flat"));
var folder = new ItemStack(Rivencraft.COLLATION_FOLDER.get());
folder.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(page("weather/off"))));
desk.setItem(1, folder);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(Rivencraft.INK_VIAL.get()));
if (desk.inkRemaining() != 16 || !desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE))
throw new IllegalStateException("Desk ink exchange regression");
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
menu.broadcastChanges();
if (menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty() || desk.inkRemaining() != 16)
throw new IllegalStateException("Desk virtual result consumed inputs");
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ContainerInput.PICKUP, player);
if (menu.getCarried().isEmpty() || desk.inkRemaining() != 15
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(0).isEmpty())
throw new IllegalStateException("Desk copy did not consume exactly one paper/ink");
menu.setCarried(ItemStack.EMPTY);
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
if (!menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty() || desk.inkRemaining() != 15)
throw new IllegalStateException("Full inventory consumed copy inputs");
player.getInventory().clearContent();
if (!menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(1, 0, 1, 26)))
throw new IllegalStateException("Desk sparse page movement failed");
if (!menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(1, 26, -1)) || menu.getCarried().isEmpty())
throw new IllegalStateException("Desk page extraction failed");
menu.setCarried(ItemStack.EMPTY);
for (int i = 0; i < 21; i++) menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
if (menu.firstVisibleTab() != 84 || !menu.clickMenuButton(player, 87)) throw new IllegalStateException("88 desk tabs not reachable");
desk.setItem(87, page("lighting/bright"));
menu.clickMenuButton(player, WritingDeskMenu.PAGE_BUTTON_BASE);
menu.broadcastChanges();
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(POS) instanceof WritingDeskBlockEntity desk)
|| desk.activeCollection() != 87 || desk.inkRemaining() != 15
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(87).isEmpty()
|| desk.displayPage().isEmpty()) throw new IllegalStateException("Desk persistence regression");
}
}
@@ -0,0 +1,17 @@
package net.dnorberg.rivencraft.block;
import net.minecraft.core.BlockPos;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** Solid crystal-textured arrival footing that cannot be collected. */
public final class CloudPlatformBlock extends Block {
public CloudPlatformBlock(Properties properties) { super(properties); }
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return ItemStack.EMPTY;
}
}
@@ -0,0 +1,83 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.entity.LivingEntity;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.phys.BlockHitResult;
public final class WritingDeskBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public static final BooleanProperty EXTENSION = BooleanProperty.create("extension");
public WritingDeskBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(EXTENSION, false));
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING, EXTENSION);
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getHorizontalDirection().getOpposite();
BlockPos extension = context.getClickedPos().relative(facing.getClockWise());
return context.getLevel().getBlockState(extension).canBeReplaced(context)
? defaultBlockState().setValue(FACING, facing) : null;
}
@Override public void setPlacedBy(Level level, BlockPos pos, BlockState state,
@Nullable LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
level.setBlock(pos.relative(state.getValue(FACING).getClockWise()),
state.setValue(EXTENSION, true), 3);
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return state.getValue(EXTENSION) ? null : new WritingDeskBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
BlockPos controller = state.getValue(EXTENSION)
? pos.relative(state.getValue(FACING).getCounterClockWise()) : pos;
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(controller) instanceof WritingDeskBlockEntity desk) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new WritingDeskMenu(id, inventory, desk),
Component.translatable("container.rivencraft.writing_desk")),
buffer -> {
buffer.writeBlockPos(controller);
WritingDeskCatalog.write(buffer, WritingDeskCatalog.available(desk));
});
}
return InteractionResult.SUCCESS;
}
@Override protected void affectNeighborsAfterRemoval(BlockState state, net.minecraft.server.level.ServerLevel level,
BlockPos pos, boolean movedByPiston) {
BlockPos other = pos.relative(state.getValue(FACING).getClockWise(), state.getValue(EXTENSION) ? -1 : 1);
BlockState otherState = level.getBlockState(other);
if (otherState.is(this) && otherState.getValue(EXTENSION) != state.getValue(EXTENSION)
&& otherState.getValue(FACING) == state.getValue(FACING))
level.setBlock(other, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 35);
Containers.updateNeighboursAfterDestroy(state, level, pos);
}
}
@@ -112,6 +112,36 @@ public final class PortBookBinderBlockEntity extends BlockEntity implements Cont
return true;
}
/** Validate the complete collection before moving any page, as in 1.21.1. */
public ItemStack importCollection(ItemStack collection, int index) {
if (!net.dnorberg.rivencraft.item.CollationFolderItem.isCollection(collection)
|| collection.getCount() != 1) return ItemStack.EMPTY;
var contents = collection.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(),
net.dnorberg.rivencraft.writing.NotebookContents.EMPTY);
int end = nextPageIndex();
if (index < 0 || index > end) return ItemStack.EMPTY;
ItemStack panel = ItemStack.EMPTY;
List<ItemStack> writing = new ArrayList<>();
for (ItemStack page : contents.pages()) {
if (page.isEmpty()) continue;
if (!page.is(Rivencraft.PAGE.get()) || page.getCount() != 1) return ItemStack.EMPTY;
if (PageItem.isLinkPanel(page)) {
if (!panel.isEmpty() || !items.get(PANEL).isEmpty()) return ItemStack.EMPTY;
panel = page;
} else if (validSymbolPage(page)) writing.add(page);
else return ItemStack.EMPTY;
}
if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && panel.isEmpty()) return ItemStack.EMPTY;
if (!panel.isEmpty()) items.set(PANEL, panel.copy());
for (int current = end - 1; current >= index; current--)
items.set(FIRST_PAGE + current + writing.size(), items.get(FIRST_PAGE + current));
for (ItemStack page : writing) items.set(FIRST_PAGE + index++, page.copy());
ItemStack emptied = collection.copy();
emptied.set(Rivencraft.NOTEBOOK_CONTENTS.get(), net.dnorberg.rivencraft.writing.NotebookContents.EMPTY);
setChanged();
return emptied;
}
public ItemStack takePage(int index) {
int end = nextPageIndex();
if (index < 0 || index >= end) return ItemStack.EMPTY;
@@ -0,0 +1,173 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** Collection tabs, paper, ink vials and empty-vial return are owned by the block. */
public final class WritingDeskBlockEntity extends BlockEntity implements Container {
public static final int COLLECTIONS = 88;
public static final int INK = COLLECTIONS;
public static final int RETURN = INK + 1;
public static final int PAPER = INK + 2;
/** Unused by new desks; retained so old saves cannot silently lose slot contents. */
public static final int LEGACY_OUTPUT = INK + 3;
public static final int SIZE = INK + 4;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int activeCollection;
private int inkRemaining;
private ItemStack displayPage = ItemStack.EMPTY;
public WritingDeskBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), pos, state);
}
public int activeCollection() { return activeCollection; }
public int inkRemaining() { return inkRemaining; }
public ItemStack displayPage() { return displayPage; }
public void setDisplayPage(ItemStack page) {
ItemStack next = page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1);
if (!ItemStack.matches(displayPage, next)) {
displayPage = next;
setChanged();
}
}
public void setActiveCollection(int index) {
if (index >= 0 && index < COLLECTIONS && activeCollection != index) {
activeCollection = index;
setChanged();
}
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) {
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == RETURN) refillEmptyInkBar();
}
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == INK || slot == RETURN) refillEmptyInkBar();
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition)
&& level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
if (slot < COLLECTIONS) return CollationFolderItem.isCollection(stack)
&& stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|| stack.is(Rivencraft.PAGE.get())
&& PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
return switch (slot) {
case INK -> stack.is(Rivencraft.INK_VIAL.get());
case RETURN -> false;
case PAPER -> stack.is(Items.PAPER);
case LEGACY_OUTPUT -> false;
default -> false;
};
}
/** Consume one vial only when the empty meter can return its glass bottle. */
private void refillEmptyInkBar() {
if (level != null && !level.isClientSide() && inkRemaining == 0) {
int addedInk = InkVialExchange.exchange(items, INK, RETURN);
if (addedInk > 0) {
inkRemaining = addedInk;
setChanged();
}
}
}
/** The output is virtual; inputs are committed only after a result can be given to a player. */
public boolean canConsumeCopyMaterials() {
ItemStack paper = items.get(PAPER);
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get();
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
}
public boolean consumeCopyMaterials() {
if (!canConsumeCopyMaterials()) return false;
int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get();
if (inkRemaining < cost) {
inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
}
inkRemaining -= cost;
removeItem(PAPER, 1);
setChanged();
if (inkRemaining == 0) refillEmptyInkBar();
return true;
}
@Override public void setLevel(Level level) {
super.setLevel(level);
refillEmptyInkBar();
}
@Override public void clearContent() {
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
displayPage = ItemStack.EMPTY;
setChanged();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
activeCollection = Math.clamp(input.getIntOr("ActiveCollection", 0), 0, COLLECTIONS - 1);
inkRemaining = Math.clamp(input.getIntOr("InkRemaining", 0), 0, InkVialItem.CAPACITY);
displayPage = input.read("DisplayPage", ItemStack.CODEC).orElse(ItemStack.EMPTY);
refillEmptyInkBar();
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
output.putInt("ActiveCollection", activeCollection);
output.putInt("InkRemaining", inkRemaining);
if (!displayPage.isEmpty()) output.store("DisplayPage", ItemStack.CODEC, displayPage);
}
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
@Override public void setChanged() {
super.setChanged();
if (level != null && !level.isClientSide()) {
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
}
}
@@ -26,6 +26,7 @@ public final class AgeCatalogClient {
@SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle);
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
if (!(context.listener() instanceof ClientPacketListener listener)) return;
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
@@ -0,0 +1,20 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
import net.minecraft.client.Minecraft;
import net.neoforged.neoforge.network.handling.IPayloadContext;
public final class ClientWritingDeskCatalog {
private ClientWritingDeskCatalog() {}
public static void handle(WritingDeskCatalogPayload payload, IPayloadContext context) {
context.enqueueWork(() -> {
var player = Minecraft.getInstance().player;
if (player != null && player.containerMenu instanceof WritingDeskMenu menu
&& menu.containerId == payload.containerId()) {
menu.setCatalog(payload.symbols());
}
});
}
}
@@ -0,0 +1,57 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.model.object.book.BookModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.entity.state.EntityRenderState;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.resources.Identifier;
/** Uses the same skyward-facing open book and textures as the 1.21.1 renderer. */
public final class DroppedBookRenderer extends EntityRenderer<DroppedBookEntity, DroppedBookRenderer.State> {
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
private static final Identifier DESCRIPTIVE = Identifier.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;
}
public static final class State extends EntityRenderState {
float yaw;
boolean descriptive;
}
@Override
public State createRenderState() { return new State(); }
@Override
public void extractRenderState(DroppedBookEntity entity, State state, float partialTick) {
super.extractRenderState(entity, state, partialTick);
state.yaw = entity.getYRot();
state.descriptive = entity.getItem().is(Rivencraft.DESCRIPTIVE_BOOK.get());
}
@Override
public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
pose.pushPose();
pose.translate(0.0F, 0.0625F, 0.0F);
pose.rotateDegrees(Axis.YP, state.yaw + 90.0F);
pose.rotateDegrees(Axis.ZP, 90.0F);
pose.scale(0.8F, 0.8F, 0.8F);
collector.submitModel(model, BookModel.State.forAnimation(0, 0, 0, 1.2F), pose,
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY,
state.outlineColor);
pose.popPose();
super.submit(state, pose, collector, camera);
}
}
@@ -0,0 +1,317 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import java.util.Locale;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
/** A page surface modelled on Legacy's folder GUI, backed by the Notebook menu. */
public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu> {
private static final int SURFACE_TOP = 20;
private static final int SURFACE_BOTTOM = 132;
private static final int SURFACE_RIGHT = 156;
private static final int PAGE_WIDTH = 30;
private static final int PAGE_HEIGHT = 40;
private static final int PAGE_STEP_X = 31;
private static final int PAGE_STEP_Y = 41;
private static final int PAGE_COLUMNS = 5;
private static final Identifier PAGE_ATLAS = texture("gui/notebook_pages");
private EditBox textInput;
private boolean draggingScrollbar;
private boolean surfaceClickPendingRelease;
private int selectedSlot = -1;
private int scroll;
public NotebookScreen(NotebookMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 213);
inventoryLabelY = 123;
}
@Override public boolean isPauseScreen() { return false; }
@Override
protected void init() {
super.init();
Button sortButton = Button.builder(Component.literal("AZ"), button -> {})
.bounds(leftPos, topPos, 18, 18).build();
Button allButton = Button.builder(Component.literal("ALL"), button -> {})
.bounds(leftPos + 20, topPos, 26, 18).build();
sortButton.active = false;
allButton.active = false;
addRenderableWidget(sortButton);
addRenderableWidget(allButton);
textInput = new EditBox(font, leftPos + 48, topPos, 128, 18,
Component.translatable("screen.rivencraft.notebook.search"));
textInput.setMaxLength(32);
textInput.setHint(Component.translatable("screen.rivencraft.notebook.search"));
textInput.setResponder(value -> scroll = 0);
addRenderableWidget(textInput);
}
@Override
public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractBackground(graphics, mouseX, mouseY, partialTick);
graphics.fill(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
topPos + SURFACE_BOTTOM, 0xB0000000);
drawBeveledPanel(graphics, leftPos, topPos + 132, imageWidth, 81);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 134 + row * 18);
}
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 192);
}
@Override
public void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractContents(graphics, mouseX, mouseY, partialTick);
int pageSlots = displayedPageSlots();
scroll = Math.min(scroll, maxScroll(pageSlots));
graphics.nextStratum();
graphics.enableScissor(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT, topPos + SURFACE_BOTTOM);
for (int index = 0; index < pageSlots; index++) {
int x = leftPos + (index % PAGE_COLUMNS) * PAGE_STEP_X;
int y = topPos + SURFACE_TOP + (index / PAGE_COLUMNS) * PAGE_STEP_Y - scroll;
if (y + PAGE_HEIGHT > topPos + SURFACE_TOP && y < topPos + SURFACE_BOTTOM) drawPage(graphics, index, x, y);
}
graphics.disableScissor();
drawScrollbar(graphics, pageSlots);
}
@Override
protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
int hovered = pageAt(mouseX, mouseY);
if (hovered >= 0 && menu.getCarried().isEmpty() && !menu.getSlot(hovered).getItem().isEmpty()) {
ItemStack page = menu.getSlot(hovered).getItem();
if (matchesSearch(page)) graphics.setTooltipForNextFrame(font, page, mouseX, mouseY);
} else super.extractTooltip(graphics, mouseX, mouseY);
}
@Override
protected void extractSlots(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
// Collection slots are drawn above as large pages. Do not submit up to
// 252 additional item models at their off-screen server-slot positions.
for (int index = NotebookMenu.NOTEBOOK_SLOTS; index < menu.slots.size(); index++) {
var slot = menu.getSlot(index);
if (slot.isActive()) extractSlot(graphics, slot, mouseX, mouseY);
}
}
@Override
protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
double mouseX = event.x(), mouseY = event.y();
int button = event.button();
if (inScrollbar(mouseX, mouseY) && button == InputConstants.MOUSE_BUTTON_LEFT) {
draggingScrollbar = true;
setScrollFromMouse(mouseY, displayedPageSlots());
return true;
}
int index = pageAt(mouseX, mouseY);
if (index >= 0 && (button == InputConstants.MOUSE_BUTTON_LEFT || button == InputConstants.MOUSE_BUTTON_RIGHT)) {
ItemStack page = menu.getSlot(index).getItem();
if ((!page.isEmpty() && !matchesSearch(page))
|| (page.isEmpty() && (!menu.getCarried().is(Rivencraft.PAGE.get())
|| PageItem.isLinkPanel(menu.getCarried())))) {
return true;
}
setFocused(null);
textInput.setFocused(false);
selectedSlot = index;
slotClicked(menu.getSlot(index), index, button == InputConstants.MOUSE_BUTTON_RIGHT ? 1 : 0, ContainerInput.PICKUP);
surfaceClickPendingRelease = true;
return true;
}
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean mouseDragged(MouseButtonEvent event, double dragX, double dragY) {
double mouseY = event.y();
int button = event.button();
if (draggingScrollbar && button == InputConstants.MOUSE_BUTTON_LEFT) {
setScrollFromMouse(mouseY, displayedPageSlots());
return true;
}
return super.mouseDragged(event, dragX, dragY);
}
@Override
public boolean mouseReleased(MouseButtonEvent event) {
if (draggingScrollbar || surfaceClickPendingRelease) {
draggingScrollbar = false;
surfaceClickPendingRelease = false;
return true;
}
draggingScrollbar = false;
return super.mouseReleased(event);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (inSurface(mouseX, mouseY) || inScrollbar(mouseX, mouseY)) {
scroll = Math.clamp(scroll - (int)Math.signum(scrollY) * 20,
0, maxScroll(displayedPageSlots()));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(KeyEvent event) {
// Let the container close normally, including returning its carried
// stack, even while the search field owns keyboard focus.
if (event.isEscape()) return super.keyPressed(event);
if (textInput != null && textInput.isFocused()) {
textInput.keyPressed(event);
// EditBox does not consume ordinary letter key presses; the matching charTyped
// event handles text input. Do not pass them to the container's inventory keys.
return true;
}
if (selectedSlot >= 0 && (event.isLeft() || event.isRight())) {
moveSelected(event.isRight());
return true;
}
return super.keyPressed(event);
}
private int displayedPageSlots() {
if (menu.getCarried().is(Rivencraft.PAGE.get())) {
return menu.pageCapacity();
}
for (int index = menu.pageCapacity() - 1; index >= 0; index--) {
if (!menu.getSlot(index).getItem().isEmpty()) {
return index + 1;
}
}
return 0;
}
private boolean matchesSearch(ItemStack page) {
return textInput.getValue().isBlank()
|| page.getHoverName().getString().toLowerCase(Locale.ROOT)
.contains(textInput.getValue().toLowerCase(Locale.ROOT));
}
private int pageAt(double mouseX, double mouseY) {
if (!inSurface(mouseX, mouseY)) return -1;
int localX = (int)mouseX - leftPos;
int localY = (int)mouseY - topPos - SURFACE_TOP + scroll;
int column = localX / PAGE_STEP_X;
int index = (localY / PAGE_STEP_Y) * PAGE_COLUMNS + column;
return column < PAGE_COLUMNS && localX % PAGE_STEP_X < PAGE_WIDTH
&& localY % PAGE_STEP_Y < PAGE_HEIGHT && index < displayedPageSlots()
? index : -1;
}
private boolean inSurface(double mouseX, double mouseY) {
return mouseX >= leftPos && mouseX < leftPos + SURFACE_RIGHT
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private boolean inScrollbar(double mouseX, double mouseY) {
return mouseX >= leftPos + SURFACE_RIGHT && mouseX < leftPos + imageWidth
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private int maxScroll(int pageCount) {
int rows = (pageCount + PAGE_COLUMNS - 1) / PAGE_COLUMNS;
return Math.max(0, rows * PAGE_STEP_Y - (SURFACE_BOTTOM - SURFACE_TOP));
}
private void setScrollFromMouse(double mouseY, int pageCount) {
int maximum = maxScroll(pageCount);
double fraction = (mouseY - topPos - SURFACE_TOP - 10.0)
/ (SURFACE_BOTTOM - SURFACE_TOP - 20.0);
scroll = Math.clamp((int)(Math.clamp(fraction, 0.0, 1.0) * maximum), 0, maximum);
}
private void drawScrollbar(GuiGraphicsExtractor graphics, int pageCount) {
int x = leftPos + SURFACE_RIGHT;
int y = topPos + SURFACE_TOP;
int height = SURFACE_BOTTOM - SURFACE_TOP;
graphics.fill(x, y, x + 20, y + height, 0xFF858585);
graphics.outline(x, y, 20, height, 0xFF262626);
for (int line = y + 5; line < y + height - 4; line += 5) {
graphics.fill(x + 4, line, x + 16, line + 1, 0xFFB7B7B7);
}
int maximum = maxScroll(pageCount);
int thumbY = y + 4 + (maximum == 0 ? 0 : scroll * (height - 23) / maximum);
graphics.fill(x + 4, thumbY, x + 16, thumbY + 15, 0xFFC9C9C9);
graphics.outline(x + 4, thumbY, 12, 15, 0xFF414141);
}
private void drawPage(GuiGraphicsExtractor graphics, int index, int x, int y) {
ItemStack page = menu.getSlot(index).getItem();
boolean dimmed = page.isEmpty() || !matchesSearch(page);
// Legacy's Notebook draws the atlas' native 30x40 page, not a stretched item icon.
graphics.nextStratum();
graphics.blit(RenderPipelines.GUI_TEXTURED, PAGE_ATLAS, x, y, 156, 0, PAGE_WIDTH, PAGE_HEIGHT, 256, 256);
if (!page.isEmpty() && page.has(Rivencraft.SYMBOL_PAGE_ID.get())) {
graphics.nextStratum();
ItemStack glyph = page.copy();
glyph.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true);
graphics.pose().pushMatrix();
graphics.pose().translate(x + 1, y + 6);
graphics.pose().scale(1.75F, 1.75F);
graphics.fakeItem(glyph, 0, 0);
graphics.pose().popMatrix();
graphics.nextStratum();
}
if (dimmed) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xB8000000);
} else if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.isPageImplemented(
page.get(Rivencraft.SYMBOL_PAGE_ID.get()))) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0x55FF3333);
}
if (index == selectedSlot && !page.isEmpty()) {
graphics.outline(x, y, PAGE_WIDTH, PAGE_HEIGHT, 0xFFFFD061);
}
}
private static Identifier texture(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
}
private void moveSelected(boolean next) {
int target = selectedSlot + (next ? 1 : -1);
if (selectedSlot < 0 || target < 0 || target >= NotebookMenu.NOTEBOOK_SLOTS
|| menu.getSlot(selectedSlot).getItem().isEmpty()) return;
int buttonId = (next ? NotebookMenu.MOVE_NEXT_BASE : NotebookMenu.MOVE_PREVIOUS_BASE) + selectedSlot;
if (menu.clickMenuButton(minecraft.player, buttonId)) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
selectedSlot = target;
}
}
private static void drawBeveledPanel(GuiGraphicsExtractor graphics, int x, int y, int width, int height) {
graphics.fill(x, y, x + width, y + height, 0xFFC6C6C6);
graphics.fill(x, y, x + width, y + 2, 0xFFFFFFFF);
graphics.fill(x, y, x + 2, y + height, 0xFFFFFFFF);
graphics.fill(x, y + height - 2, x + width, y + height, 0xFF555555);
graphics.fill(x + width - 2, y, x + width, y + height, 0xFF555555);
}
private void drawSlot(GuiGraphicsExtractor graphics, int x, int y) {
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFF8B8B8B);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 1, 0xFF373737);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 1, topPos + y + 18, 0xFF373737);
graphics.fill(leftPos + x + 17, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
graphics.fill(leftPos + x, topPos + y + 17, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
}
}
@@ -18,12 +18,16 @@ public final class PortBookBinderClientEvents {
public static void registerBlockEntityRenderers(
net.neoforged.neoforge.client.event.EntityRenderersEvent.RegisterRenderers event) {
event.registerBlockEntityRenderer(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), context -> new StarFissureRenderer());
event.registerEntityRenderer(Rivencraft.DROPPED_BOOK.get(), DroppedBookRenderer::new);
event.registerBlockEntityRenderer(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), WritingDeskRenderer::new);
}
@SubscribeEvent
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::new);
event.register(Rivencraft.NOTEBOOK_MENU.get(), NotebookScreen::new);
event.register(Rivencraft.WRITING_DESK_MENU.get(), WritingDeskScreen::new);
}
@SubscribeEvent
@@ -0,0 +1,60 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.item.ItemModelResolver;
import net.minecraft.client.renderer.item.ItemStackRenderState;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.phys.Vec3;
/** The existing selected-page and white-paper display, extracted for 26.3. */
public final class WritingDeskRenderer implements BlockEntityRenderer<WritingDeskBlockEntity, WritingDeskRenderer.State> {
private final ItemModelResolver resolver;
public WritingDeskRenderer(BlockEntityRendererProvider.Context context) { resolver = context.itemModelResolver(); }
public static final class State extends BlockEntityRenderState {
final ItemStackRenderState paper = new ItemStackRenderState();
final ItemStackRenderState page = new ItemStackRenderState();
int layers;
float rotation;
}
@Override public State createRenderState() { return new State(); }
@Override public void extractRenderState(WritingDeskBlockEntity desk, State state, float partialTick,
Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) {
BlockEntityRenderer.super.extractRenderState(desk, state, partialTick, camera, overlay);
int count = desk.getItem(WritingDeskBlockEntity.PAPER).getCount();
state.layers = count == 0 ? 0 : Math.min(4, 1 + count / 16);
state.rotation = 180 - desk.getBlockState().getValue(WritingDeskBlock.FACING).toYRot();
resolver.updateForTopItem(state.paper, new ItemStack(Items.PAPER), ItemDisplayContext.FIXED, desk.getLevel(), null, 0);
resolver.updateForTopItem(state.page, desk.displayPage(), ItemDisplayContext.FIXED, desk.getLevel(), null, 0);
}
@Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
pose.pushPose();
pose.translate(0.5, 0, 0.5);
pose.rotateDegrees(Axis.YP, state.rotation);
for (int i = 0; i < state.layers; i++) draw(state.paper, pose, collector, state.lightCoords,
1.22, 0.27 + i * 0.008, -0.14, 0.55F);
if (!state.page.isEmpty()) draw(state.page, pose, collector, state.lightCoords, 1.0, 0.572, 0, 0.8F);
pose.popPose();
}
private static void draw(ItemStackRenderState item, PoseStack pose, SubmitNodeCollector collector,
int light, double x, double y, double z, float scale) {
pose.pushPose();
pose.translate(x, y, z);
pose.rotateDegrees(Axis.XP, 90);
pose.scale(scale, scale, scale);
item.submit(pose, collector, light, OverlayTexture.NO_OVERLAY, 0);
pose.popPose();
}
@Override public boolean shouldRenderOffScreen() { return true; }
}
@@ -0,0 +1,510 @@
package net.dnorberg.rivencraft.client;
import java.util.List;
import java.util.Locale;
import java.util.ArrayList;
import java.util.Comparator;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.input.KeyEvent;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.renderer.RenderPipelines;
/** Legacy desk atlas layout, with four visible tabs out of the desk's 88 collections. */
public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> {
private static final Identifier PAGE_ATLAS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/notebook_pages.png");
private static final Identifier LEGACY_DESK = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/writing_desk_legacy.png");
private static final Identifier SCROLLBAR = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/scrollbar.png");
private int lastCollection = -1;
private int selectedPage;
private int scrollRows;
private boolean allMode;
private boolean sortAlphabetically;
private int selectedCollection = -1;
private Identifier selectedCategory;
private EditBox search;
private Button categoryButton;
private Button allButton;
private Button sortButton;
private boolean draggingScrollbar;
private record DisplayPage(int collection, int index, ItemStack stack) {}
private DisplayPage pageDragSource;
private double pageDragStartX;
private double pageDragStartY;
private boolean draggingPage;
private boolean isPhysicalPageView() {
return !allMode && !sortAlphabetically && selectedCategory == null
&& (search == null || search.getValue().isBlank());
}
public WritingDeskScreen(WritingDeskMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 409, 186);
titleLabelX = 222;
}
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() {
super.init();
sortButton = addRenderableWidget(Button.builder(Component.literal("AZ"), button -> {
sortAlphabetically = !sortAlphabetically;
scrollRows = 0;
}).bounds(leftPos + 58, topPos, 20, 18)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.sort"))).build());
allButton = addRenderableWidget(Button.builder(Component.literal("ALL"), button -> {
allMode = !allMode;
scrollRows = 0;
if (!allMode) {
selectedCollection = -1;
selectedPage = 0;
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.PAGE_BUTTON_BASE);
}
}).bounds(leftPos + 80, topPos, 28, 18)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.all"))).build());
search = new EditBox(font, leftPos + 110, topPos, 101, 18,
Component.translatable("screen.rivencraft.writing_desk.search"));
search.setMaxLength(64);
search.setHint(Component.translatable("screen.rivencraft.writing_desk.search"));
search.setResponder(value -> scrollRows = 0);
addRenderableWidget(search);
categoryButton = addRenderableWidget(Button.builder(Component.literal("C"), button -> {
List<Identifier> categories = menu.catalog().stream()
.map(SymbolDisplayMetadata::category).distinct().sorted().toList();
int next = selectedCategory == null ? 0 : categories.indexOf(selectedCategory) + 1;
selectedCategory = next >= categories.size() ? null : categories.get(next);
categoryButton.setMessage(Component.literal(selectedCategory == null ? "C" : "C*"));
scrollRows = 0;
}).bounds(leftPos + 213, topPos, 18, 18).build());
}
private List<DisplayPage> filteredPages() {
String query = search == null ? "" : search.getValue().trim().toLowerCase(Locale.ROOT);
List<DisplayPage> entries = new ArrayList<>();
int from = allMode ? 0 : menu.activeCollection();
int until = allMode ? WritingDeskBlockEntity.COLLECTIONS : from + 1;
for (int collection = from; collection < until; collection++) {
List<ItemStack> pages = pages(collection);
int last = -1;
for (int i = 0; i < pages.size(); i++) if (!pages.get(i).isEmpty()) last = i;
// A notebook has exactly its normal GUI capacity; a loose page has one position.
if (isPhysicalPageView()) last = menu.pageCapacity(collection) - 1;
for (int i = 0; i <= last; i++) {
ItemStack page = i < pages.size() ? pages.get(i) : ItemStack.EMPTY;
if (page.isEmpty() && (allMode || sortAlphabetically || !query.isEmpty() || selectedCategory != null)) continue;
if (!page.isEmpty() && !query.isEmpty()
&& !page.getHoverName().getString().toLowerCase(Locale.ROOT).contains(query)
&& !PageItem.content(page).symbol().map(symbol -> symbol.id().toString()
.toLowerCase(Locale.ROOT).contains(query)).orElse(false)) continue;
if (!page.isEmpty() && selectedCategory != null) {
Identifier id = PageItem.content(page).symbol().map(symbol -> symbol.id()).orElse(null);
if (menu.catalog().stream().noneMatch(metadata -> metadata.id().equals(id)
&& metadata.category().equals(selectedCategory))) continue;
}
entries.add(new DisplayPage(collection, i, page));
}
}
if (sortAlphabetically) entries.sort(Comparator
.comparing((DisplayPage entry) -> entry.stack().getHoverName().getString(), String.CASE_INSENSITIVE_ORDER)
.thenComparingInt(DisplayPage::collection).thenComparingInt(DisplayPage::index));
return entries;
}
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
int x = leftPos;
int y = topPos;
// Legacy GuiWritingDesk draws the entire 176x166 panel, not a stretched swatch.
graphics.blit(RenderPipelines.GUI_TEXTURED, LEGACY_DESK, x + 233, y + 20, 0, 0, 176, 166, 256, 256);
drawTabControl(graphics, 20, 203, menu.firstVisibleTab() > 0);
for (int i = 0; i < 4; i++) {
int tab = menu.firstVisibleTab() + i;
int tabY = y + 29 + i * 37;
graphics.blit(RenderPipelines.GUI_TEXTURED, LEGACY_DESK, x, tabY, 0, 166, 58, 37, 256, 256,
tab == menu.activeCollection() && !allMode ? 0xFF8080FF : -1);
// Legacy numbers its Writing Desk collections from zero.
drawNumeral(graphics, tab, x + (tab < 25 ? 8 : 4), tabY + 3, 19);
ItemStack collection = menu.getSlot(tab).getItem();
if (!collection.isEmpty()) {
String name = collection.getHoverName().getString();
float scale = Math.min(1F, 50F / Math.max(1, font.width(name)));
graphics.pose().pushMatrix();
graphics.pose().translate(x + 4, tabY + 25);
graphics.pose().scale(scale, scale);
graphics.text(font, name, 0, 0, 0xFF404040, false);
graphics.pose().popMatrix();
}
}
drawTabControl(graphics, 177, 212, menu.firstVisibleTab() < WritingDeskBlockEntity.COLLECTIONS - 4);
// GuiElementVSlider: 4-pixel caps, repeated 22-pixel track, 12x15 thumb.
graphics.blit(RenderPipelines.GUI_TEXTURED, SCROLLBAR, x + 213, y + 20, 0, 0, 20, 4, 32, 32);
for (int offset = 4; offset < 162; offset += 22)
graphics.blit(RenderPipelines.GUI_TEXTURED, SCROLLBAR, x + 213, y + 20 + offset, 0, 4, 20, Math.min(22, 162 - offset), 32, 32);
graphics.blit(RenderPipelines.GUI_TEXTURED, SCROLLBAR, x + 213, y + 182, 0, 26, 20, 4, 32, 32);
int totalRows = (filteredPages().size() + 4) / 5;
int maxScroll = Math.max(0, totalRows - 4);
scrollRows = Math.clamp(scrollRows, 0, maxScroll);
int thumbY = y + 24 + (maxScroll == 0 ? 0 : scrollRows * 143 / maxScroll);
graphics.blit(RenderPipelines.GUI_TEXTURED, SCROLLBAR, x + 217, thumbY, 20, draggingScrollbar ? 15 : 0, 12, 15, 32, 32);
graphics.fill(x + 365, y + 27, x + 381, y + 97, 0xFF303035);
graphics.fill(x + 366, y + 28, x + 380, y + 96, 0xFF6666AA);
int filled = 68 * menu.inkRemaining() / 16;
if (filled > 0) graphics.fill(x + 366, y + 96 - filled, x + 380, y + 96, 0xFF15151B);
}
private void drawTabControl(GuiGraphicsExtractor graphics, int y, int textureY, boolean enabled) {
graphics.blit(RenderPipelines.GUI_TEXTURED, LEGACY_DESK, leftPos, topPos + y, 0, textureY, 58, 9, 256, 256,
enabled ? -1 : 0xFF666666);
}
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
// Legacy leaves the inventory grid unlabeled.
}
@Override public void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractContents(graphics, mouseX, mouseY, partialTick);
if (sortAlphabetically) graphics.outline(leftPos + 58, topPos, 20, 18, 0xFFFFD061);
if (allMode) graphics.outline(leftPos + 80, topPos, 28, 18, 0xFFFFD061);
renderCollection(graphics);
if (draggingPage && pageDragSource != null) {
DisplayPage target = pageAtMouse(mouseX, mouseY);
if (target != null && canMovePage(pageDragSource, target)) {
int x = leftPos + 58 + ((int) mouseX - leftPos - 58) / 31 * 31;
int y = topPos + 20 + ((int) mouseY - topPos - 20) / 41 * 41;
graphics.outline(x, y, 30, 40, 0xFF57D478);
}
graphics.fakeItem(pageDragSource.stack(), mouseX - 8, mouseY - 8);
}
}
@Override protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
super.extractTooltip(graphics, mouseX, mouseY);
if (mouseX >= leftPos + 365 && mouseX < leftPos + 381
&& mouseY >= topPos + 27 && mouseY < topPos + 97) {
graphics.setTooltipForNextFrame(font, Component.translatable("screen.rivencraft.writing_desk.ink",
menu.inkRemaining(), 16), mouseX, mouseY);
}
if (mouseX >= leftPos + 213 && mouseX < leftPos + 231
&& mouseY >= topPos && mouseY < topPos + 18) {
graphics.setTooltipForNextFrame(font, Component.translatable("screen.rivencraft.writing_desk.category",
selectedCategory == null
? Component.translatable("screen.rivencraft.writing_desk.all_categories")
: Component.literal(selectedCategory.toString())), mouseX, mouseY);
}
if (mouseX >= leftPos && mouseX < leftPos + 58) {
int localY = mouseY - topPos;
if (localY >= 20 && localY < 29)
graphics.setTooltipForNextFrame(font, Component.translatable("screen.rivencraft.writing_desk.previous_tabs"), mouseX, mouseY);
else if (localY >= 177 && localY < 186)
graphics.setTooltipForNextFrame(font, Component.translatable("screen.rivencraft.writing_desk.next_tabs"), mouseX, mouseY);
else if (mouseX < leftPos + 34 && localY >= 29 && localY < 177) {
int tab = menu.firstVisibleTab() + (localY - 29) / 37;
graphics.setTooltipForNextFrame(font, Component.translatable("screen.rivencraft.writing_desk.tab_symbol",
tab), mouseX, mouseY);
}
}
}
private List<ItemStack> pages(int collectionIndex) {
ItemStack collection = menu.getSlot(collectionIndex).getItem();
if (collection.is(Rivencraft.PAGE.get())) return List.of(collection);
if (CollationFolderItem.isCollection(collection)) {
return collection.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
}
return List.of();
}
private void renderCollection(GuiGraphicsExtractor graphics) {
if (lastCollection != menu.activeCollection()) {
lastCollection = menu.activeCollection();
scrollRows = 0;
selectedPage = 0;
selectedCollection = -1;
}
List<DisplayPage> entries = filteredPages();
for (int i = scrollRows * 5; i < entries.size() && i < scrollRows * 5 + 20; i++) {
int column = i % 5;
int row = i / 5 - scrollRows;
DisplayPage entry = entries.get(i);
ItemStack page = entry.stack();
int x = leftPos + 58 + column * 31;
int y = topPos + 20 + row * 41;
drawPage(graphics, page, x, y);
if (page.isEmpty()) graphics.fill(x, y, x + 30, y + 40, 0xB8000000);
int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
if (entry.collection() == active && entry.index() == selectedPage && !page.isEmpty())
graphics.outline(x, y, 30, 40, 0xFFFFD061);
}
int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
List<ItemStack> source = pages(active);
if (selectedPage >= source.size() || source.get(selectedPage).isEmpty()) return;
ItemStack selected = source.get(selectedPage);
drawPage(graphics, selected, leftPos + 265, topPos + 26);
graphics.fill(leftPos + 261, topPos + 81, leftPos + 360, topPos + 95, 0xFF202020);
var symbol = PageItem.content(selected).symbol();
if (symbol.isPresent() && PageItem.isCorrupted(symbol.orElseThrow())) {
graphics.enableScissor(leftPos + 263, topPos + 82, leftPos + 358, topPos + 94);
graphics.text(font, selected.getHoverName(), leftPos + 263, topPos + 84,
0xFFE0E0E0, false);
graphics.disableScissor();
} else {
String title = font.plainSubstrByWidth(selected.getHoverName().getString(), 95);
graphics.text(font, title, leftPos + 263, topPos + 84, 0xFFE0E0E0, false);
}
}
private void drawPage(GuiGraphicsExtractor graphics, ItemStack page, int x, int y) {
graphics.nextStratum();
graphics.blit(RenderPipelines.GUI_TEXTURED, PAGE_ATLAS, x, y, 156, 0, 30, 40, 256, 256);
if (page.isEmpty()) return;
if (page.has(Rivencraft.SYMBOL_PAGE_ID.get())) {
graphics.nextStratum();
ItemStack glyph = page.copy();
glyph.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true);
graphics.pose().pushMatrix();
graphics.pose().translate(x + 1, y + 6);
graphics.pose().scale(1.75F, 1.75F);
graphics.fakeItem(glyph, 0, 0);
graphics.pose().popMatrix();
graphics.nextStratum();
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.isPageImplemented(page.get(Rivencraft.SYMBOL_PAGE_ID.get())))
graphics.fill(x, y, x + 30, y + 40, 0x55FF3333);
}
}
private static void drawNumeral(GuiGraphicsExtractor graphics, int number, int x, int y, int size) {
int[] digits = net.dnorberg.rivencraft.writing.PortSymbolNumerals.digitsFor(number);
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
Identifier components = Identifier.fromNamespaceAndPath("rivencraft", "textures/gui/symbolcomponents.png");
for (int digit : digits) {
for (int component : net.dnorberg.rivencraft.writing.PortSymbolNumerals.componentsForDigit(digit))
graphics.blit(RenderPipelines.GUI_TEXTURED, components, x, y + (size - digitSize) / 2,
(component % 8) * 64, (component / 8) * 64, digitSize, digitSize, 64, 64, 512, 512, 0xFF211A14);
x += digitSize + 1;
}
}
private DisplayPage pageAtMouse(double mouseX, double mouseY) {
int x = (int) Math.floor(mouseX - leftPos - 58);
int y = (int) Math.floor(mouseY - topPos - 20);
if (x < 0 || x >= 155 || y < 0 || y >= 164 || x % 31 >= 30 || y % 41 >= 40) return null;
int index = (y / 41 + scrollRows) * 5 + x / 31;
List<DisplayPage> entries = filteredPages();
return index < entries.size() ? entries.get(index) : null;
}
private boolean canMovePage(DisplayPage source, DisplayPage target) {
return !source.stack().isEmpty()
&& (CollationFolderItem.isCollection(menu.getSlot(source.collection()).getItem())
|| menu.getSlot(source.collection()).getItem().is(Rivencraft.PAGE.get()))
&& (CollationFolderItem.isCollection(menu.getSlot(target.collection()).getItem())
|| menu.getSlot(target.collection()).getItem().is(Rivencraft.PAGE.get()))
&& (source.collection() != target.collection() || source.index() != target.index());
}
private int extractTargetSlot(double mouseX, double mouseY) {
for (Slot slot : menu.slots) {
int index = slot.index;
if (index >= WritingDeskBlockEntity.COLLECTIONS && index <= WritingDeskMenu.COPY_RESULT) continue;
if (!slot.isActive() || !isHovering(slot.x, slot.y, 16, 16, mouseX, mouseY)) continue;
ItemStack held = pageDragSource.stack();
if (!slot.mayPlace(held)) return -1;
ItemStack current = slot.getItem();
if (current.isEmpty()) return index;
if (index < WritingDeskBlockEntity.COLLECTIONS && CollationFolderItem.isCollection(current)) {
List<ItemStack> pages = current.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
if (pages.size() < CollationFolderItem.capacity(current) || pages.stream().anyMatch(ItemStack::isEmpty))
return index;
} else if (index > WritingDeskMenu.COPY_RESULT && ItemStack.isSameItemSameComponents(current, held)
&& current.getCount() < slot.getMaxStackSize(held)) return index;
return -1;
}
return -1;
}
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
double mouseX = event.x(), mouseY = event.y();
int button = event.button();
if (button == InputConstants.MOUSE_BUTTON_LEFT) {
pageDragSource = null;
draggingPage = false;
}
int surfaceX = (int) Math.floor(mouseX - leftPos - 58);
int surfaceY = (int) Math.floor(mouseY - topPos - 20);
if ((button == InputConstants.MOUSE_BUTTON_LEFT || button == InputConstants.MOUSE_BUTTON_RIGHT) && !menu.getCarried().isEmpty()
&& surfaceX >= 0 && surfaceX < 155 && surfaceY >= 0 && surfaceY < 164) {
if (menu.getCarried().is(Rivencraft.PAGE.get())
&& PageItem.content(menu.getCarried()).kind() != PageContent.Kind.LINK_PANEL
&& surfaceX % 31 < 30 && surfaceY % 41 < 40) {
int index = (surfaceY / 41 + scrollRows) * 5 + surfaceX / 31;
List<DisplayPage> entries = filteredPages();
// Only displayed positions are addressable. Filtering never turns
// empty screen space into additional storage for a loose-page tab.
int collection = index < entries.size() ? entries.get(index).collection() : menu.activeCollection();
int page = index < entries.size() ? entries.get(index).index() : index;
if (index < entries.size() && page < menu.pageCapacity(collection)
&& minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
WritingDeskMenu.PLACE_PAGE_BUTTON_BASE + collection * NotebookContents.MAX_PAGES + page);
}
}
return true;
}
if (button == InputConstants.MOUSE_BUTTON_LEFT) {
if (mouseX >= leftPos && mouseX < leftPos + 58) {
int y = (int) mouseY - topPos;
if (y >= 20 && y < 29 || y >= 177 && y < 186) {
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
y < 29 ? WritingDeskMenu.PREVIOUS_TABS : WritingDeskMenu.NEXT_TABS);
return true;
}
}
if (mouseX >= leftPos + 213 && mouseX < leftPos + 233
&& mouseY >= topPos + 20 && mouseY < topPos + 186) {
draggingScrollbar = true;
setScrollFromMouse(mouseY);
return true;
}
int x = (int) Math.floor(mouseX - leftPos - 58);
int y = (int) Math.floor(mouseY - topPos - 20);
if (x >= 0 && x < 155 && y >= 0 && y < 164) {
DisplayPage entry = pageAtMouse(mouseX, mouseY);
if (entry != null && !entry.stack().isEmpty()) {
selectedCollection = entry.collection();
selectedPage = entry.index();
if (menu.getCarried().isEmpty()) {
pageDragSource = entry;
pageDragStartX = mouseX;
pageDragStartY = mouseY;
}
if (minecraft != null && minecraft.gameMode != null) {
int id = WritingDeskMenu.ALL_PAGE_BUTTON_BASE
+ entry.collection() * NotebookContents.MAX_PAGES + entry.index();
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
}
}
return true;
}
}
if (button == InputConstants.MOUSE_BUTTON_LEFT && mouseX >= leftPos && mouseX < leftPos + 58) {
for (int i = 0; i < 4; i++) {
int top = topPos + 29 + i * 37;
boolean overSlot = mouseX >= leftPos + 36 && mouseX < leftPos + 54
&& mouseY >= top + 4 && mouseY < top + 22;
if (mouseY >= top && mouseY < top + 37 && !overSlot) {
allMode = false;
selectedCollection = -1;
if (minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, menu.firstVisibleTab() + i);
}
return true;
}
}
}
return super.mouseClicked(event, doubleClick);
}
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (mouseX >= leftPos + 58 && mouseX < leftPos + 233
&& mouseY >= topPos + 20 && mouseY < topPos + 186) {
int rows = (filteredPages().size() + 4) / 5;
scrollRows = Math.clamp(scrollRows - (int) Math.signum(scrollY), 0, Math.max(0, rows - 4));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
private void setScrollFromMouse(double mouseY) {
int rows = (filteredPages().size() + 4) / 5;
int max = Math.max(0, rows - 4);
scrollRows = Math.clamp((int) Math.round((mouseY - topPos - 31.5) * max / 143.0), 0, max);
}
@Override public boolean mouseDragged(MouseButtonEvent event, double dragX, double dragY) {
double mouseX = event.x(), mouseY = event.y();
int button = event.button();
if (button == InputConstants.MOUSE_BUTTON_LEFT && pageDragSource != null) {
if (Math.abs(mouseX - pageDragStartX) >= 4 || Math.abs(mouseY - pageDragStartY) >= 4)
draggingPage = true;
return true;
}
if (button == InputConstants.MOUSE_BUTTON_LEFT && draggingScrollbar) {
setScrollFromMouse(mouseY);
return true;
}
return super.mouseDragged(event, dragX, dragY);
}
@Override public boolean mouseReleased(MouseButtonEvent event) {
double mouseX = event.x(), mouseY = event.y();
int button = event.button();
if (button == InputConstants.MOUSE_BUTTON_LEFT && pageDragSource != null) {
if (draggingPage) {
DisplayPage target = pageAtMouse(mouseX, mouseY);
if (target != null && canMovePage(pageDragSource, target)
&& minecraft != null && minecraft.gameMode != null) {
selectedCollection = target.collection();
selectedPage = target.index();
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
WritingDeskMenu.movePageButtonId(pageDragSource.collection(), pageDragSource.index(),
target.collection(), target.index()));
} else if (target == null && minecraft != null && minecraft.gameMode != null) {
int targetSlot = extractTargetSlot(mouseX, mouseY);
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
WritingDeskMenu.extractPageButtonId(pageDragSource.collection(), pageDragSource.index(),
targetSlot));
}
}
pageDragSource = null;
draggingPage = false;
return true;
}
if (button == InputConstants.MOUSE_BUTTON_LEFT && draggingScrollbar) {
draggingScrollbar = false;
return true;
}
return super.mouseReleased(event);
}
@Override public boolean keyPressed(KeyEvent event) {
if (event.isEscape()) return super.keyPressed(event);
if (search != null && search.isFocused()) {
search.keyPressed(event);
return true;
}
if (minecraft != null && minecraft.gameMode != null) {
if (event.key() == InputConstants.KEY_PAGEUP) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.PREVIOUS_TABS);
return true;
}
if (event.key() == InputConstants.KEY_PAGEDOWN) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.NEXT_TABS);
return true;
}
}
return super.keyPressed(event);
}
}
@@ -0,0 +1,51 @@
package net.dnorberg.rivencraft.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.server.level.ServerLevel;
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.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.Vec3;
/** Recoverable open-book entity, with the 1.21.1 decay and weather behavior. */
public final class DroppedBookEntity extends ItemEntity {
private static final int SLOW_DECAY_INTERVAL = 10_000;
private int decayTicks;
public DroppedBookEntity(EntityType<? extends DroppedBookEntity> type, Level level) { super(type, level); }
public DroppedBookEntity(Level level, Vec3 position, ItemStack book) {
this(Rivencraft.DROPPED_BOOK.get(), level);
setPos(position.x, position.y + 0.25, position.z);
setDeltaMovement(Vec3.ZERO);
setItem(book);
setUnlimitedLifetime();
setPickUpDelay(20);
}
@Override
public void tick() {
super.tick();
if (!(level() instanceof ServerLevel serverLevel) || isRemoved()) return;
if (++decayTicks >= SLOW_DECAY_INTERVAL) {
decayTicks = 0;
hurtServer(serverLevel, damageSources().starve(), 1.0F);
}
if (!isRemoved() && isInWaterOrRain()) hurtServer(serverLevel, damageSources().drown(), 1.0F);
}
@Override
protected void addAdditionalSaveData(ValueOutput output) {
super.addAdditionalSaveData(output);
output.putInt("BookDecayTicks", decayTicks);
}
@Override
protected void readAdditionalSaveData(ValueInput input) {
super.readAdditionalSaveData(input);
decayTicks = Math.clamp(input.getIntOr("BookDecayTicks", 0), 0, SLOW_DECAY_INTERVAL - 1);
}
}
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.Rivencraft;
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(Rivencraft.NOTEBOOK.get()) || stack.is(Rivencraft.COLLATION_FOLDER.get())
|| stack.is(Rivencraft.SYMBOL_PORTFOLIO.get());
}
public static int capacity(ItemStack stack) {
return stack.is(Rivencraft.COLLATION_FOLDER.get()) ? MAX_PAGES
: stack.is(Rivencraft.NOTEBOOK.get()) || stack.is(Rivencraft.SYMBOL_PORTFOLIO.get())
? NotebookContents.MAX_PAGES : 0;
}
}
@@ -0,0 +1,44 @@
package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
import net.minecraft.world.level.Level;
/** The 1.21.1 collection editor opened through a server-owned menu. */
public class NotebookItem extends Item {
public NotebookItem(Properties properties) { super(properties); }
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
ItemStack notebook = player.getItemInHand(hand);
if (player instanceof ServerPlayer serverPlayer) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new NotebookMenu(id, inventory, hand), notebook.getHoverName()),
buffer -> buffer.writeBoolean(hand == InteractionHand.OFF_HAND));
}
return InteractionResult.SUCCESS;
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
var contents = stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
tooltip.accept(Component.translatable("item.rivencraft.notebook.pages", contents.pageCount(),
CollationFolderItem.capacity(stack)).withStyle(ChatFormatting.GRAY));
if (contents.unresolvedCount() > 0) tooltip.accept(Component.translatable(
"item.rivencraft.notebook.unresolved", contents.unresolvedCount()).withStyle(ChatFormatting.RED));
}
}
@@ -42,6 +42,22 @@ public final class PageItem extends Item {
return stack;
}
public static ItemStack createSymbolPage(net.dnorberg.rivencraft.writing.SymbolReference symbol) {
return createSymbolPage(symbol.id());
}
/** Resolved view of the port's existing components; no second saved schema. */
public static net.dnorberg.rivencraft.writing.PageContent content(ItemStack stack) {
var id = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
if (isLinkPanel(stack)) return net.dnorberg.rivencraft.writing.PageContent.LINK_PANEL;
return id == null ? net.dnorberg.rivencraft.writing.PageContent.BLANK
: net.dnorberg.rivencraft.writing.PageContent.symbol(
new net.dnorberg.rivencraft.writing.SymbolReference(id)).resolve();
}
public static boolean isUsableWriting(ItemStack stack) { return content(stack).isResolvedSymbol(); }
public static boolean isCorrupted(net.dnorberg.rivencraft.writing.SymbolReference symbol) { return isCorrupted(symbol.id()); }
@Override
public Component getName(ItemStack stack) {
Component customName = stack.get(DataComponents.CUSTOM_NAME);
@@ -0,0 +1,35 @@
package net.dnorberg.rivencraft.item;
import java.util.function.Supplier;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
/** Leaves only the active book behind after successful handheld travel. */
public final class PortHandheldTravel {
private PortHandheldTravel() {}
public static String travel(ServerPlayer player, InteractionHand hand, ItemStack book,
Supplier<String> travel) {
if (book.isEmpty() || player.getItemInHand(hand) != book) return "Book is no longer in the active hand";
var source = player.level();
var departure = player.position();
player.setItemInHand(hand, ItemStack.EMPTY);
boolean dropped = false;
try {
String failure = travel.get();
if (failure != null) return failure;
dropped = source.addFreshEntity(new DroppedBookEntity(source, departure, book));
return null;
} finally {
// Rejected travel or a cancelled entity spawn must not consume the book.
if (!dropped) {
if (player.getItemInHand(hand).isEmpty()) player.setItemInHand(hand, book);
else if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
}
player.containerMenu.broadcastChanges();
}
}
}
@@ -0,0 +1,43 @@
package net.dnorberg.rivencraft.item;
import java.util.ArrayList;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
/** The 1.21.1 booster item, opened into a 16-page Collation Folder. */
public final class SealedNotebookItem extends Item {
public SealedNotebookItem(Properties properties) { super(properties); }
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResult.PASS;
ItemStack sealed = player.getItemInHand(hand);
ItemStack folder = open(serverPlayer);
if (folder.isEmpty()) return InteractionResult.FAIL;
if (!player.hasInfiniteMaterials()) sealed.shrink(1);
if (sealed.isEmpty()) player.setItemInHand(hand, folder);
else if (!player.getInventory().add(folder)) player.drop(folder, false, Prediction.SERVER_ONLY);
return InteractionResult.SUCCESS;
}
public static ItemStack open(ServerPlayer player) {
var candidates = PortSymbolPages.sealedCandidateIds();
if (candidates.isEmpty()) return ItemStack.EMPTY;
var pages = new ArrayList<ItemStack>(16);
for (int i = 0; i < 16; i++) pages.add(PageItem.createSymbolPage(
candidates.get(player.getRandom().nextInt(candidates.size()))));
ItemStack folder = new ItemStack(Rivencraft.COLLATION_FOLDER.get());
folder.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
return folder;
}
}
@@ -0,0 +1,39 @@
package net.dnorberg.rivencraft.item;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
/** Non-craftable, prefilled Legacy-style collections of one symbol-page category. */
public final class SymbolPortfolioItem extends NotebookItem {
public SymbolPortfolioItem(Properties properties) {
super(properties);
}
public static List<ItemStack> createVolumes(Identifier category, List<ItemStack> pages) {
if (pages.isEmpty()) return List.of();
int capacity = NotebookContents.MAX_PAGES;
int volumeCount = (pages.size() + capacity - 1) / capacity;
List<ItemStack> volumes = new ArrayList<>(volumeCount);
for (int volume = 0; volume < volumeCount; volume++) {
ItemStack portfolio = new ItemStack(Rivencraft.SYMBOL_PORTFOLIO.get());
int first = volume * capacity;
int last = Math.min(first + capacity, pages.size());
portfolio.set(Rivencraft.NOTEBOOK_CONTENTS.get(),
new NotebookContents(pages.subList(first, last)));
Component categoryName = Component.translatableWithFallback(
"itemGroup.rivencraft.pages." + category.getPath(), category.getPath());
portfolio.set(DataComponents.CUSTOM_NAME, volumeCount == 1
? Component.translatable("item.rivencraft.symbol_portfolio.named", categoryName)
: Component.translatable("item.rivencraft.symbol_portfolio.volume",
categoryName, volume + 1, volumeCount));
volumes.add(portfolio);
}
return List.copyOf(volumes);
}
}
@@ -0,0 +1,205 @@
package net.dnorberg.rivencraft.menu;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.minecraft.core.NonNullList;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
public final class NotebookMenu extends AbstractContainerMenu {
public static final int NOTEBOOK_SLOTS = NotebookContents.MAX_PAGES;
public static final int MOVE_PREVIOUS_BASE = 0;
public static final int MOVE_NEXT_BASE = NOTEBOOK_SLOTS;
private static final int PLAYER_INVENTORY_START = NOTEBOOK_SLOTS;
private final Inventory playerInventory;
private final InteractionHand hand;
private final int openedHotbarSlot;
private final NotebookContainer contents;
private final int pageCapacity;
public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) {
this(containerId, inventory, data.readBoolean() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND);
}
public NotebookMenu(int containerId, Inventory inventory, InteractionHand hand) {
super(Rivencraft.NOTEBOOK_MENU.get(), containerId);
this.playerInventory = inventory;
this.hand = hand;
this.openedHotbarSlot = inventory.getSelectedSlot();
this.pageCapacity = CollationFolderItem.capacity(inventory.player.getItemInHand(hand));
this.contents = new NotebookContainer(inventory, hand);
for (int page = 0; page < NOTEBOOK_SLOTS; page++) {
// The client draws these pages on a filtered, scrollable surface instead.
addSlot(new PageSlot(contents, page, -1000, -1000, page < pageCapacity));
}
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
int inventoryIndex = column + row * 9 + 9;
addSlot(playerSlot(inventoryIndex, 8 + column * 18, 135 + row * 18));
}
}
for (int column = 0; column < 9; column++) {
addSlot(playerSlot(column, 8 + column * 18, 193));
}
}
private Slot playerSlot(int inventoryIndex, int x, int y) {
boolean locked = hand == InteractionHand.MAIN_HAND && inventoryIndex == openedHotbarSlot;
return locked ? new LockedSlot(playerInventory, inventoryIndex, x, y)
: new Slot(playerInventory, inventoryIndex, x, y);
}
public int pageCapacity() { return pageCapacity; }
@Override
public boolean stillValid(Player player) {
return player.isAlive()
&& (hand == InteractionHand.OFF_HAND || player.getInventory().getSelectedSlot() == openedHotbarSlot)
&& CollationFolderItem.isCollection(player.getItemInHand(hand));
}
@Override
public ItemStack quickMoveStack(Player player, int index) {
Slot slot = slots.get(index);
if (!slot.hasItem()) {
return ItemStack.EMPTY;
}
ItemStack source = slot.getItem();
ItemStack original = source.copy();
if (index < PLAYER_INVENTORY_START) {
if (!moveItemStackTo(source, PLAYER_INVENTORY_START, slots.size(), true)) {
return ItemStack.EMPTY;
}
} else {
if (!source.is(Rivencraft.PAGE.get())
|| PageItem.isLinkPanel(source)
|| !moveItemStackTo(source, 0, PLAYER_INVENTORY_START, false)) {
return ItemStack.EMPTY;
}
}
if (source.isEmpty()) {
slot.setByPlayer(ItemStack.EMPTY);
} else {
slot.setChanged();
}
return original;
}
@Override
public boolean clickMenuButton(Player player, int id) {
int source;
int target;
if (id >= MOVE_PREVIOUS_BASE && id < MOVE_NEXT_BASE) {
source = id;
target = source - 1;
} else if (id >= MOVE_NEXT_BASE && id < MOVE_NEXT_BASE + NOTEBOOK_SLOTS) {
source = id - MOVE_NEXT_BASE;
target = source + 1;
} else {
return false;
}
if (source < 0 || source >= pageCapacity || target < 0 || target >= pageCapacity
|| contents.getItem(source).isEmpty()) {
return false;
}
contents.swap(source, target);
broadcastChanges();
return true;
}
@Override
public void clicked(int slotId, int button, ContainerInput clickType, Player player) {
if (!stillValid(player)) {
player.closeContainer();
return;
}
if (clickType == ContainerInput.SWAP
&& (button == openedHotbarSlot && hand == InteractionHand.MAIN_HAND
|| button == 40 && hand == InteractionHand.OFF_HAND)) {
return;
}
super.clicked(slotId, button, clickType, player);
}
private static final class PageSlot extends Slot {
private final boolean enabled;
private PageSlot(Container container, int slot, int x, int y, boolean enabled) {
super(container, slot, x, y);
this.enabled = enabled;
}
@Override public boolean mayPlace(ItemStack stack) {
return enabled && stack.is(Rivencraft.PAGE.get())
&& !PageItem.isLinkPanel(stack);
}
@Override public boolean mayPickup(Player player) { return enabled; }
@Override public int getMaxStackSize() { return 1; }
}
private static final class LockedSlot extends Slot {
private LockedSlot(Container container, int slot, int x, int y) {
super(container, slot, x, y);
}
@Override public boolean mayPickup(Player player) { return false; }
@Override public boolean mayPlace(ItemStack stack) { return false; }
}
private static final class NotebookContainer extends SimpleContainer {
private final Inventory inventory;
private final InteractionHand hand;
private boolean loading;
private NotebookContainer(Inventory inventory, InteractionHand hand) {
super(NOTEBOOK_SLOTS);
this.inventory = inventory;
this.hand = hand;
ItemStack notebook = inventory.player.getItemInHand(hand);
loading = true;
List<ItemStack> saved = notebook.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
for (int index = 0; index < saved.size(); index++) {
super.setItem(index, saved.get(index).copy());
}
loading = false;
}
private void swap(int first, int second) {
loading = true;
ItemStack held = getItem(first);
setItem(first, getItem(second));
setItem(second, held);
loading = false;
setChanged();
}
@Override
public void setChanged() {
super.setChanged();
ItemStack notebook = inventory.player.getItemInHand(hand);
if (loading || !CollationFolderItem.isCollection(notebook)) {
return;
}
int capacity = CollationFolderItem.capacity(notebook);
NonNullList<ItemStack> saved = NonNullList.withSize(capacity, ItemStack.EMPTY);
for (int index = 0; index < capacity; index++) {
ItemStack page = getItem(index);
saved.set(index, page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1));
}
notebook.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(saved));
}
}
}
@@ -72,6 +72,14 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
if (player.level().isClientSide()) return true;
int index = id - STRIP_CLICK_BASE;
ItemStack carried = getCarried();
if (net.dnorberg.rivencraft.item.CollationFolderItem.isCollection(carried)) {
ItemStack emptied = blockEntity.importCollection(carried,
Math.min(index == 0 ? 0 : index - 1, blockEntity.nextPageIndex()));
if (emptied.isEmpty()) return false;
setCarried(emptied);
broadcastChanges();
return true;
}
if (!carried.isEmpty()) {
if (index == 0) {
if (!PageItem.isLinkPanel(carried)
@@ -145,6 +153,12 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
} else if (stack.is(Items.LEATHER)) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.COVER,
PortBookBinderBlockEntity.COVER + 1, false);
} else if (net.dnorberg.rivencraft.item.CollationFolderItem.isCollection(stack)) {
ItemStack emptied = blockEntity.importCollection(stack, blockEntity.nextPageIndex());
if (emptied.isEmpty()) return ItemStack.EMPTY;
slot.setByPlayer(emptied);
broadcastChanges();
return original;
} else if (PageItem.isLinkPanel(stack)) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.PANEL,
PortBookBinderBlockEntity.PANEL + 1, false);
@@ -0,0 +1,463 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.network.PacketDistributor;
import java.util.List;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.DataSlot;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
public final class WritingDeskMenu extends AbstractContainerMenu {
public static final int COPY_RESULT = WritingDeskBlockEntity.SIZE;
public static final int VISIBLE_TABS = 4;
public static final int PREVIOUS_TABS = 90;
public static final int NEXT_TABS = 91;
public static final int PAGE_BUTTON_BASE = 128;
public static final int CATALOG_BUTTON_BASE = 512;
public static final int ALL_PAGE_BUTTON_BASE = 32768;
public static final int PLACE_PAGE_BUTTON_BASE = 65536;
private static final int PAGE_ADDRESS_COUNT = WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES;
public static final int MOVE_PAGE_BUTTON_BASE = PLACE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT;
private static final int PLAYER_START = COPY_RESULT + 1;
private static final int EXTRACT_TARGET_COUNT = PLAYER_START + 36 + 1;
public static final int EXTRACT_PAGE_BUTTON_BASE = MOVE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * PAGE_ADDRESS_COUNT;
private final Container desk;
private final WritingDeskBlockEntity blockEntity;
private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide;
private int syncedActiveCollection;
private int syncedInkRemaining;
private int firstVisibleTab;
private int selectedPage;
private int selectedCollection = -1;
private Identifier selectedSymbol;
private List<SymbolDisplayMetadata> catalog = List.of();
private final ServerPlayer serverViewer;
public WritingDeskMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof WritingDeskBlockEntity found ? found : null);
catalog = WritingDeskCatalog.read(data);
}
public WritingDeskMenu(int id, Inventory inventory, WritingDeskBlockEntity blockEntity) {
super(Rivencraft.WRITING_DESK_MENU.get(), id);
this.blockEntity = blockEntity;
this.desk = blockEntity == null ? new SimpleContainer(WritingDeskBlockEntity.SIZE) : blockEntity;
this.clientSide = inventory.player.level().isClientSide();
this.serverViewer = inventory.player instanceof ServerPlayer serverPlayer ? serverPlayer : null;
if (blockEntity != null && !clientSide) catalog = WritingDeskCatalog.available(blockEntity);
firstVisibleTab = blockEntity == null ? 0 : blockEntity.activeCollection() / VISIBLE_TABS * VISIBLE_TABS;
addDataSlot(new DataSlot() {
@Override public int get() { return firstVisibleTab; }
@Override public void set(int value) { firstVisibleTab = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedActiveCollection : WritingDeskMenu.this.blockEntity.activeCollection(); }
@Override public void set(int value) { syncedActiveCollection = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedInkRemaining : WritingDeskMenu.this.blockEntity.inkRemaining(); }
@Override public void set(int value) { syncedInkRemaining = value; }
});
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) {
addSlot(new DeskSlot(desk, i, 37, 34 + (i % VISIBLE_TABS) * 37, 1));
}
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.INK, 385, 28, 16));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.RETURN, 385, 80, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.PAPER, 241, 28, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.LEGACY_OUTPUT, -1000, -1000, 64));
addSlot(new CopySlot(result, 0, 241, 80));
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
addSlot(new Slot(inventory, column + row * 9 + 9, 241 + column * 18, 104 + row * 18));
}
}
for (int column = 0; column < 9; column++) {
addSlot(new Slot(inventory, column, 241 + column * 18, 162));
}
}
public int activeCollection() { return syncedActiveCollection; }
public int firstVisibleTab() { return firstVisibleTab; }
public int inkRemaining() { return syncedInkRemaining; }
public int pageCapacity(int collectionIndex) {
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS) return 0;
ItemStack collection = desk.getItem(collectionIndex);
return CollationFolderItem.isCollection(collection) ? CollationFolderItem.capacity(collection)
: collection.is(Rivencraft.PAGE.get()) ? 1 : 0;
}
public List<SymbolDisplayMetadata> catalog() { return catalog; }
public void setCatalog(List<SymbolDisplayMetadata> symbols) { catalog = List.copyOf(symbols); }
public static int movePageButtonId(int sourceCollection, int sourcePage, int targetCollection, int targetPage) {
int source = sourceCollection * NotebookContents.MAX_PAGES + sourcePage;
int target = targetCollection * NotebookContents.MAX_PAGES + targetPage;
return MOVE_PAGE_BUTTON_BASE + source * PAGE_ADDRESS_COUNT + target;
}
/** targetSlot is -1 for the cursor, otherwise a visible player or desk-tab slot. */
public static int extractPageButtonId(int sourceCollection, int sourcePage, int targetSlot) {
int source = sourceCollection * NotebookContents.MAX_PAGES + sourcePage;
return EXTRACT_PAGE_BUTTON_BASE + source * EXTRACT_TARGET_COUNT + targetSlot + 1;
}
@Override public boolean clickMenuButton(Player player, int id) {
if (blockEntity == null || !stillValid(player)) return false;
if (id == PREVIOUS_TABS || id == NEXT_TABS) {
firstVisibleTab = Math.clamp(firstVisibleTab + (id == NEXT_TABS ? VISIBLE_TABS : -VISIBLE_TABS),
0, WritingDeskBlockEntity.COLLECTIONS - VISIBLE_TABS);
return true;
} else if (id >= 0 && id < WritingDeskBlockEntity.COLLECTIONS) {
if (id < firstVisibleTab || id >= firstVisibleTab + VISIBLE_TABS) return false;
blockEntity.setActiveCollection(id);
selectedPage = 0;
selectedCollection = -1;
selectedSymbol = null;
} else if (id >= EXTRACT_PAGE_BUTTON_BASE
&& id < EXTRACT_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * EXTRACT_TARGET_COUNT) {
int offset = id - EXTRACT_PAGE_BUTTON_BASE;
int source = offset / EXTRACT_TARGET_COUNT;
int targetSlot = offset % EXTRACT_TARGET_COUNT - 1;
if (!extractPage(source / NotebookContents.MAX_PAGES, source % NotebookContents.MAX_PAGES,
targetSlot)) return false;
} else if (id >= MOVE_PAGE_BUTTON_BASE && id < MOVE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * PAGE_ADDRESS_COUNT) {
int offset = id - MOVE_PAGE_BUTTON_BASE;
int source = offset / PAGE_ADDRESS_COUNT;
int target = offset % PAGE_ADDRESS_COUNT;
if (!movePage(source / NotebookContents.MAX_PAGES, source % NotebookContents.MAX_PAGES,
target / NotebookContents.MAX_PAGES, target % NotebookContents.MAX_PAGES)) return false;
} else if (id >= PLACE_PAGE_BUTTON_BASE
&& id < PLACE_PAGE_BUTTON_BASE + WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES) {
int offset = id - PLACE_PAGE_BUTTON_BASE;
if (!placeCarriedPage(offset / NotebookContents.MAX_PAGES, offset % NotebookContents.MAX_PAGES)) return false;
} else if (id >= PAGE_BUTTON_BASE && id < PAGE_BUTTON_BASE + NotebookContents.MAX_PAGES) {
selectedPage = id - PAGE_BUTTON_BASE;
selectedCollection = -1;
selectedSymbol = null;
} else if (id >= ALL_PAGE_BUTTON_BASE && id < ALL_PAGE_BUTTON_BASE + WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES) {
int offset = id - ALL_PAGE_BUTTON_BASE;
int collection = offset / NotebookContents.MAX_PAGES;
int page = offset % NotebookContents.MAX_PAGES;
if (pageAt(collection, page).isEmpty()) return false;
selectedCollection = collection;
selectedPage = page;
selectedSymbol = null;
} else if (id >= CATALOG_BUTTON_BASE && id < CATALOG_BUTTON_BASE + catalog.size()) {
List<SymbolDisplayMetadata> available = WritingDeskCatalog.available(blockEntity);
if (!catalog.equals(available)) {
refreshCatalog(available);
return false;
}
selectedSymbol = available.get(id - CATALOG_BUTTON_BASE).id();
} else return false;
refreshOutput();
return true;
}
private boolean placeCarriedPage(int collectionIndex, int pageIndex) {
// Resolve the actual storage position on the server, never a filtered list index.
ItemStack carried = getCarried();
if (clientSide || !storablePage(carried)) return false;
ItemStack collection = desk.getItem(collectionIndex);
if (CollationFolderItem.isCollection(collection)) {
if (pageIndex < 0 || pageIndex >= CollationFolderItem.capacity(collection)) return false;
var pages = new java.util.ArrayList<>(collection.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
while (pages.size() <= pageIndex) pages.add(ItemStack.EMPTY);
if (!pages.get(pageIndex).isEmpty()) return false;
pages.set(pageIndex, carried.copyWithCount(1));
ItemStack updated = collection.copy();
updated.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
desk.setItem(collectionIndex, updated);
} else return false;
carried.shrink(1);
setCarried(carried.isEmpty() ? ItemStack.EMPTY : carried);
selectedCollection = collectionIndex;
selectedPage = pageIndex;
selectedSymbol = null;
refreshCatalog();
broadcastChanges();
return true;
}
private boolean movePage(int sourceCollection, int sourcePage, int targetCollection, int targetPage) {
if (!getCarried().isEmpty() || sourceCollection == targetCollection && sourcePage == targetPage) return false;
ItemStack sourceBook = desk.getItem(sourceCollection);
ItemStack targetBook = desk.getItem(targetCollection);
boolean sourceNotebook = CollationFolderItem.isCollection(sourceBook);
boolean targetNotebook = CollationFolderItem.isCollection(targetBook);
if (sourceNotebook && sourcePage >= CollationFolderItem.capacity(sourceBook)
|| targetNotebook && targetPage >= CollationFolderItem.capacity(targetBook)) return false;
if ((!sourceNotebook && (!sourceBook.is(Rivencraft.PAGE.get()) || sourcePage != 0))
|| (!targetNotebook && (!targetBook.is(Rivencraft.PAGE.get()) || targetPage != 0))) return false;
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
if (sourceNotebook && (sourcePage >= sourcePages.size() || sourcePages.get(sourcePage).isEmpty())) return false;
ItemStack moving = sourceNotebook ? sourcePages.get(sourcePage) : sourceBook;
if (moving.getCount() != 1) return false;
if (!storablePage(moving)) return false;
if (!targetNotebook && targetBook.getCount() != 1) return false;
var targetPages = targetNotebook ? sourceCollection == targetCollection ? sourcePages
: new java.util.ArrayList<>(targetBook.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
if (targetNotebook) while (targetPages.size() <= targetPage) targetPages.add(ItemStack.EMPTY);
ItemStack displaced = targetNotebook ? targetPages.get(targetPage) : targetBook;
if (!displaced.isEmpty() && !storablePage(displaced)) return false;
if (targetNotebook) targetPages.set(targetPage, moving);
if (sourceNotebook) {
sourcePages.set(sourcePage, displaced);
ItemStack updatedSource = sourceBook.copy();
updatedSource.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(sourcePages));
desk.setItem(sourceCollection, updatedSource);
} else desk.setItem(sourceCollection, displaced.isEmpty() ? ItemStack.EMPTY : displaced.copyWithCount(1));
if (targetNotebook && sourceCollection != targetCollection) {
ItemStack updatedTarget = targetBook.copy();
updatedTarget.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(targetPages));
desk.setItem(targetCollection, updatedTarget);
} else if (!targetNotebook) desk.setItem(targetCollection, moving.copyWithCount(1));
selectedCollection = targetCollection;
selectedPage = targetPage;
selectedSymbol = null;
refreshCatalog();
broadcastChanges();
return true;
}
private boolean extractPage(int sourceCollection, int sourcePage, int targetSlot) {
if (!getCarried().isEmpty() || targetSlot < -1 || targetSlot >= slots.size()) return false;
ItemStack sourceBook = desk.getItem(sourceCollection);
boolean sourceNotebook = CollationFolderItem.isCollection(sourceBook);
if (sourceNotebook && sourcePage >= CollationFolderItem.capacity(sourceBook)) return false;
if (!sourceNotebook && (!sourceBook.is(Rivencraft.PAGE.get()) || sourcePage != 0)) return false;
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
if (sourceNotebook && (sourcePage >= sourcePages.size() || sourcePages.get(sourcePage).isEmpty())) return false;
ItemStack moving = sourceNotebook ? sourcePages.get(sourcePage).copyWithCount(1) : sourceBook.copyWithCount(1);
if (!sourceNotebook && sourceBook.getCount() != 1) return false;
if (targetSlot == -1) {
setCarried(moving);
} else {
if (targetSlot >= WritingDeskBlockEntity.COLLECTIONS && targetSlot < PLAYER_START) return false;
if (targetSlot < WritingDeskBlockEntity.COLLECTIONS && !storablePage(moving)) return false;
Slot target = slots.get(targetSlot);
if (!target.isActive() || !target.mayPlace(moving)) return false;
ItemStack current = target.getItem();
if (targetSlot < WritingDeskBlockEntity.COLLECTIONS && CollationFolderItem.isCollection(current)) {
var targetPages = targetSlot == sourceCollection ? sourcePages
: new java.util.ArrayList<>(current.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
int position = -1;
for (int i = 0; i < CollationFolderItem.capacity(current); i++) {
if (i >= targetPages.size() || targetPages.get(i).isEmpty()) {
position = i;
break;
}
}
if (position < 0) return false;
while (targetPages.size() <= position) targetPages.add(ItemStack.EMPTY);
targetPages.set(position, moving);
if (targetSlot != sourceCollection) {
ItemStack updatedTarget = current.copy();
updatedTarget.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(targetPages));
target.setByPlayer(updatedTarget);
}
} else if (current.isEmpty()) {
target.setByPlayer(moving);
} else if (targetSlot >= PLAYER_START && ItemStack.isSameItemSameComponents(current, moving)
&& current.getCount() < target.getMaxStackSize(moving)) {
target.setByPlayer(current.copyWithCount(current.getCount() + 1));
} else return false;
}
if (sourceNotebook) {
sourcePages.set(sourcePage, ItemStack.EMPTY);
ItemStack updatedSource = sourceBook.copy();
updatedSource.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(sourcePages));
desk.setItem(sourceCollection, updatedSource);
} else desk.setItem(sourceCollection, ItemStack.EMPTY);
selectedCollection = sourceCollection;
selectedPage = sourcePage;
selectedSymbol = null;
refreshCatalog();
broadcastChanges();
return true;
}
@Override public boolean stillValid(Player player) {
return blockEntity != null && blockEntity.stillValid(player);
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
if (index == COPY_RESULT) {
refreshOutput();
if (!canCopy()) return ItemStack.EMPTY;
ItemStack copy = selectedSource().copyWithCount(1);
ItemStack movingCopy = copy.copy();
if (!moveItemStackTo(movingCopy, PLAYER_START, slots.size(), true) || !movingCopy.isEmpty()) {
return ItemStack.EMPTY;
}
if (!blockEntity.consumeCopyMaterials()) return ItemStack.EMPTY;
refreshOutput();
return copy;
}
Slot slot = slots.get(index);
if (!slot.isActive()) return ItemStack.EMPTY;
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack moving = slot.getItem();
ItemStack original = moving.copy();
if (index < COPY_RESULT) {
if (!moveItemStackTo(moving, PLAYER_START, slots.size(), true)) return ItemStack.EMPTY;
} else if (!moveIntoDesk(moving)) {
return ItemStack.EMPTY;
}
if (moving.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
else slot.setChanged();
return original;
}
private boolean moveIntoDesk(ItemStack moving) {
if (moving.is(Rivencraft.INK_VIAL.get())) return moveItemStackTo(moving,
WritingDeskBlockEntity.INK, WritingDeskBlockEntity.INK + 1, false);
if (CollationFolderItem.isCollection(moving)) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
if (moving.is(Items.PAPER))
return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER,
WritingDeskBlockEntity.PAPER + 1, false);
if (storablePage(moving)) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
return false;
}
private static boolean storablePage(ItemStack stack) {
return stack.is(Rivencraft.PAGE.get())
&& PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
}
private ItemStack selectedSource() {
if (selectedSymbol != null) {
SymbolReference reference = new SymbolReference(selectedSymbol);
return PortSymbolPages.find(reference.id()) != null
&& blockEntity != null
&& WritingDeskCatalog.available(blockEntity).stream()
.anyMatch(entry -> entry.id().equals(selectedSymbol))
? PageItem.createSymbolPage(reference) : ItemStack.EMPTY;
}
int collectionIndex = selectedCollection >= 0 ? selectedCollection
: blockEntity == null ? syncedActiveCollection : blockEntity.activeCollection();
return pageAt(collectionIndex, selectedPage);
}
private ItemStack pageAt(int collectionIndex, int pageIndex) {
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS
|| pageIndex < 0 || pageIndex >= NotebookContents.MAX_PAGES) return ItemStack.EMPTY;
ItemStack collection = desk.getItem(collectionIndex);
if (collection.is(Rivencraft.PAGE.get())) return pageIndex == 0 ? collection : ItemStack.EMPTY;
if (CollationFolderItem.isCollection(collection)) {
if (pageIndex >= CollationFolderItem.capacity(collection)) return ItemStack.EMPTY;
List<ItemStack> pages = collection.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
return pageIndex < pages.size() ? pages.get(pageIndex) : ItemStack.EMPTY;
}
return ItemStack.EMPTY;
}
private boolean canCopy() {
ItemStack source = selectedSource();
return blockEntity != null && source.is(Rivencraft.PAGE.get())
&& PageItem.content(source).kind() != PageContent.Kind.BLANK
&& PageItem.content(source).kind() != PageContent.Kind.LINK_PANEL
&& PageItem.isUsableWriting(source) && blockEntity.canConsumeCopyMaterials();
}
private void refreshOutput() {
if (clientSide) return;
if (blockEntity != null) blockEntity.setDisplayPage(selectedSource());
ItemStack preview = canCopy() ? selectedSource().copyWithCount(1) : ItemStack.EMPTY;
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
}
private void refreshCatalog() {
if (clientSide || blockEntity == null) return;
refreshCatalog(WritingDeskCatalog.available(blockEntity));
}
private void refreshCatalog(List<SymbolDisplayMetadata> current) {
if (clientSide || blockEntity == null) return;
if (!current.equals(catalog)) {
catalog = current;
if (serverViewer != null && serverViewer.containerMenu == this) {
PacketDistributor.sendToPlayer(serverViewer, new WritingDeskCatalogPayload(containerId, catalog));
}
}
}
@Override public void broadcastChanges() {
refreshCatalog();
refreshOutput();
super.broadcastChanges();
}
@Override public void clicked(int slotId, int button, net.minecraft.world.inventory.ContainerInput clickType,
Player player) {
if (!stillValid(player)) {
player.closeContainer();
return;
}
refreshCatalog();
refreshOutput();
if (slotId >= 0 && slotId < slots.size() && !slots.get(slotId).isActive()) return;
super.clicked(slotId, button, clickType, player);
}
private final class CopySlot extends Slot {
private CopySlot(Container container, int index, int x, int y) { super(container, index, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) {
return clientSide ? hasItem() : WritingDeskMenu.this.stillValid(player) && canCopy();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && blockEntity.consumeCopyMaterials()) {
refreshOutput();
}
super.onTake(player, stack);
}
}
private final class DeskSlot extends Slot {
private final int limit;
private DeskSlot(Container container, int index, int x, int y, int limit) {
super(container, index, x, y);
this.limit = limit;
}
@Override public boolean isActive() {
int slot = getContainerSlot();
return slot >= WritingDeskBlockEntity.COLLECTIONS
|| slot >= firstVisibleTab && slot < firstVisibleTab + VISIBLE_TABS;
}
@Override public boolean mayPickup(Player player) { return isActive(); }
@Override public boolean mayPlace(ItemStack stack) { return isActive() && container.canPlaceItem(getContainerSlot(), stack); }
@Override public int getMaxStackSize() { return limit; }
}
}
@@ -33,6 +33,7 @@ public final class PortBookNetworking {
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC);
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) bind(player, payload);
@@ -82,7 +83,8 @@ public final class PortBookNetworking {
private static void use(ServerPlayer player, UseLinkingBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.LINKING_BOOK.get())) return;
String failure = LinkingBookItem.travel(player, stack);
String failure = net.dnorberg.rivencraft.item.PortHandheldTravel.travel(player,
hand(payload.offhand()), stack, () -> LinkingBookItem.travel(player, stack));
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
}
@@ -168,13 +170,19 @@ public final class PortBookNetworking {
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
if (id == null) return;
ServerLevel source = player.level();
LinkBookTarget returnTarget = LinkBookTarget.at(player);
Vec3 departure = player.position();
Vec3 incoming = player.getDeltaMovement();
String failure = PortAgeTravel.enter(player, id);
String failure = net.dnorberg.rivencraft.item.PortHandheldTravel.travel(player,
hand(payload.offhand()), stack, () -> {
String result = PortAgeTravel.enter(player, id);
if (result == null) LinkingBookItem.applySuccessfulLink(player,
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
source, departure, incoming, player.getYRot());
return result;
});
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
else LinkingBookItem.applySuccessfulLink(player,
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
source, departure, incoming, player.getYRot());
else net.dnorberg.rivencraft.age.PortReturnLinks.afterSuccessfulTravel(player, id, returnTarget);
}
private static InteractionHand hand(boolean offhand) {
@@ -0,0 +1,26 @@
package net.dnorberg.rivencraft.network;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Current server-approved symbols from this open Writing Desk menu. */
public record WritingDeskCatalogPayload(int containerId, List<SymbolDisplayMetadata> symbols)
implements CustomPacketPayload {
public static final Type<WritingDeskCatalogPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "writing_desk_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, WritingDeskCatalogPayload> STREAM_CODEC =
StreamCodec.of(
(buffer, payload) -> {
buffer.writeVarInt(payload.containerId());
WritingDeskCatalog.write(buffer, payload.symbols());
},
buffer -> new WritingDeskCatalogPayload(buffer.readVarInt(), WritingDeskCatalog.read(buffer)));
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -32,6 +32,11 @@ public final class ModCreativeTabs {
output.accept(Rivencraft.INK_VIAL.get());
output.accept(Rivencraft.BOOK_BINDER_ITEM.get());
output.accept(Rivencraft.INK_MIXER_ITEM.get());
output.accept(Rivencraft.WRITING_DESK_ITEM.get());
output.accept(Rivencraft.NOTEBOOK.get());
output.accept(Rivencraft.COLLATION_FOLDER.get());
output.accept(Rivencraft.SEALED_NOTEBOOK.get());
creativeSymbolPortfolios(parameters.holders()).forEach(output::accept);
})
.build());
TABS.register("rivencraft_link_pages", () -> CreativeModeTab.builder()
@@ -65,6 +70,15 @@ public final class ModCreativeTabs {
private ModCreativeTabs() {
}
public static java.util.List<net.minecraft.world.item.ItemStack> creativeSymbolPortfolios(
net.minecraft.core.HolderLookup.Provider registries) {
var portfolios = new java.util.ArrayList<net.minecraft.world.item.ItemStack>();
for (String category : PortSymbolPages.categories()) portfolios.addAll(
net.dnorberg.rivencraft.item.SymbolPortfolioItem.createVolumes(
net.minecraft.resources.Identifier.parse(category), PortSymbolPages.pages(category, registries)));
return java.util.List.copyOf(portfolios);
}
public static void register(IEventBus modEventBus) {
TABS.register(modEventBus);
}
@@ -0,0 +1,90 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.world.item.ItemStack;
/** A bounded, position-preserving collection of Page stacks stored on a Notebook item. */
public record NotebookContents(List<ItemStack> pages) {
// Legacy did not impose a book-page cap. Our fixed-slot binder persists slot IDs
// as unsigned bytes; 252 page positions (after its three input slots) fit safely.
public static final int MAX_PAGES = 252;
public static final NotebookContents EMPTY = new NotebookContents(List.of());
public static final Codec<NotebookContents> CODEC = ItemStack.OPTIONAL_CODEC.listOf()
.validate(NotebookContents::validate)
.xmap(NotebookContents::new, NotebookContents::pages);
public static final StreamCodec<RegistryFriendlyByteBuf, NotebookContents> STREAM_CODEC = StreamCodec.of(
NotebookContents::encode, NotebookContents::decode);
public NotebookContents {
if (pages.size() > MAX_PAGES) {
throw new IllegalArgumentException("Notebook exceeds " + MAX_PAGES + " page positions");
}
List<ItemStack> copies = new ArrayList<>(pages.size());
for (ItemStack page : pages) {
validatePage(page).getOrThrow();
copies.add(page.copy());
}
pages = List.copyOf(copies);
}
public int pageCount() {
return (int)pages.stream().filter(page -> !page.isEmpty()).count();
}
public int unresolvedCount() {
return (int)pages.stream()
.filter(page -> !page.isEmpty()
&& page.has(Rivencraft.SYMBOL_PAGE_ID.get())
&& PortSymbolPages.find(page.get(Rivencraft.SYMBOL_PAGE_ID.get())) == null)
.count();
}
private static DataResult<List<ItemStack>> validate(List<ItemStack> pages) {
if (pages.size() > MAX_PAGES) {
return DataResult.error(() -> "Notebook exceeds " + MAX_PAGES + " page positions");
}
for (ItemStack page : pages) {
DataResult<ItemStack> result = validatePage(page);
if (result.error().isPresent()) {
return DataResult.error(() -> result.error().orElseThrow().message());
}
}
return DataResult.success(pages);
}
private static DataResult<ItemStack> validatePage(ItemStack page) {
if (page.isEmpty()) {
return DataResult.success(page);
}
if (!page.is(Rivencraft.PAGE.get())) {
return DataResult.error(() -> "Notebook contains a non-Page item");
}
return page.getCount() == 1
? DataResult.success(page)
: DataResult.error(() -> "Notebook Page stacks must contain exactly one item");
}
private static void encode(RegistryFriendlyByteBuf buffer, NotebookContents contents) {
ByteBufCodecs.VAR_INT.encode(buffer, contents.pages.size());
contents.pages.forEach(page -> ItemStack.OPTIONAL_STREAM_CODEC.encode(buffer, page));
}
private static NotebookContents decode(RegistryFriendlyByteBuf buffer) {
int size = ByteBufCodecs.VAR_INT.decode(buffer);
if (size < 0 || size > MAX_PAGES) {
throw new IllegalArgumentException("Invalid Notebook page count " + size);
}
List<ItemStack> pages = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
pages.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(buffer));
}
return new NotebookContents(pages);
}
}
@@ -0,0 +1,95 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** Persistent contents of a Page item. Unknown symbols remain explicit and recoverable. */
public record PageContent(Kind kind, Optional<SymbolReference> symbol) {
public static final PageContent BLANK = new PageContent(Kind.BLANK, Optional.empty());
public static final PageContent LINK_PANEL = new PageContent(Kind.LINK_PANEL, Optional.empty());
private static final Codec<Kind> KIND_CODEC = Codec.STRING.comapFlatMap(
value -> {
try {
return DataResult.success(Kind.valueOf(value.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return DataResult.error(() -> "Unknown page content kind: " + value);
}
},
value -> value.name().toLowerCase(Locale.ROOT)
);
private static final Codec<PageContent> RECORD_CODEC = RecordCodecBuilder.create(instance -> instance.group(
KIND_CODEC.fieldOf("kind").forGetter(PageContent::kind),
SymbolReference.CODEC.optionalFieldOf("symbol").forGetter(PageContent::symbol)
).apply(instance, PageContent::new));
public static final Codec<PageContent> CODEC = RECORD_CODEC.flatXmap(PageContent::validate, PageContent::validate);
public static final StreamCodec<ByteBuf, PageContent> STREAM_CODEC = StreamCodec.of(
PageContent::encode, PageContent::decode);
public PageContent {
Objects.requireNonNull(kind, "kind");
symbol = Objects.requireNonNull(symbol, "symbol");
boolean needsSymbol = kind == Kind.SYMBOL || kind == Kind.UNRESOLVED_SYMBOL;
if (needsSymbol != symbol.isPresent()) {
throw new IllegalArgumentException("Page contents do not match kind " + kind);
}
}
public static PageContent symbol(SymbolReference symbol) {
return new PageContent(Kind.SYMBOL, Optional.of(symbol));
}
public static PageContent unresolved(SymbolReference symbol) {
return new PageContent(Kind.UNRESOLVED_SYMBOL, Optional.of(symbol));
}
/** Re-resolves persisted data against the authoritative current server catalog. */
public PageContent resolve() {
if (kind != Kind.SYMBOL && kind != Kind.UNRESOLVED_SYMBOL) {
return this;
}
SymbolReference reference = symbol.orElseThrow();
return PortSymbolPages.find(reference.id()) != null ? symbol(reference) : unresolved(reference);
}
public boolean isResolvedSymbol() {
return kind == Kind.SYMBOL;
}
private static DataResult<PageContent> validate(PageContent content) {
boolean needsSymbol = content.kind == Kind.SYMBOL || content.kind == Kind.UNRESOLVED_SYMBOL;
return needsSymbol == content.symbol.isPresent()
? DataResult.success(content)
: DataResult.error(() -> "Page contents do not match kind " + content.kind);
}
private static void encode(ByteBuf buffer, PageContent content) {
ByteBufCodecs.VAR_INT.encode(buffer, content.kind.ordinal());
content.symbol.ifPresent(reference -> SymbolReference.STREAM_CODEC.encode(buffer, reference));
}
private static PageContent decode(ByteBuf buffer) {
int ordinal = ByteBufCodecs.VAR_INT.decode(buffer);
if (ordinal < 0 || ordinal >= Kind.values().length) {
throw new IllegalArgumentException("Unknown page content kind ordinal " + ordinal);
}
Kind kind = Kind.values()[ordinal];
Optional<SymbolReference> symbol = kind == Kind.SYMBOL || kind == Kind.UNRESOLVED_SYMBOL
? Optional.of(SymbolReference.STREAM_CODEC.decode(buffer))
: Optional.empty();
return new PageContent(kind, symbol);
}
public enum Kind {
BLANK,
SYMBOL,
UNRESOLVED_SYMBOL,
LINK_PANEL
}
}
@@ -103,6 +103,7 @@ public final class PortRegistryPages {
private static String materialCategory(Block block) {
if (block == null || block == Blocks.AIR || block == Blocks.CAVE_AIR || block == Blocks.VOID_AIR
|| block == Rivencraft.CLOUD_PLATFORM.get()
|| block instanceof EntityBlock || block.defaultBlockState().hasBlockEntity()) return null;
var state = block.defaultBlockState();
var fluid = state.getFluidState();
@@ -4,6 +4,17 @@ package net.dnorberg.rivencraft.writing;
public final class PortSymbolNumerals {
private PortSymbolNumerals() {}
public static int[] digitsFor(int number) {
if (number < 0) throw new IllegalArgumentException("Numeral must be non-negative");
if (number < 25) return new int[] {number};
int count = 0;
for (int remaining = number; remaining > 0; remaining /= 25) count++;
int[] digits = new int[count];
for (int remaining = number, index = count - 1; index >= 0; index--, remaining /= 25)
digits[index] = remaining % 25;
return digits;
}
public static int[] componentsForDigit(int digit) {
if (digit < 0 || digit >= 25) throw new IllegalArgumentException("Base-25 digit must be in [0, 24]");
if (digit == 0) return new int[] {1};
@@ -37,6 +37,15 @@ public final class PortSymbolPages {
return List.copyOf(categories);
}
/** Same bundled, non-biome/material/non-corrupted pool as 1.21.1's booster. */
public static List<Identifier> sealedCandidateIds() {
return ENTRIES.values().stream().filter(entry -> entry.id().getNamespace().equals(Rivencraft.MOD_ID)
&& !entry.id().getPath().startsWith("block/")
&& !entry.id().getPath().startsWith("biome/")
&& !entry.category().equals("rivencraft:corrupted"))
.map(Entry::id).sorted().toList();
}
public static List<ItemStack> pages(String category, HolderLookup.Provider registries) {
List<ItemStack> pages = new ArrayList<>();
ENTRIES.values().stream().filter(entry -> entry.category().equals(category))
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
/** Client-safe symbol presentation selected by the authoritative server catalog. */
public record SymbolDisplayMetadata(
Identifier id,
String nameTranslationKey,
String descriptionTranslationKey,
Identifier category,
boolean resolved,
String poem
) {
public static final Codec<SymbolDisplayMetadata> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("id").forGetter(SymbolDisplayMetadata::id),
Codec.STRING.fieldOf("name").forGetter(SymbolDisplayMetadata::nameTranslationKey),
Codec.STRING.fieldOf("description").forGetter(SymbolDisplayMetadata::descriptionTranslationKey),
Identifier.CODEC.fieldOf("category").forGetter(SymbolDisplayMetadata::category),
Codec.BOOL.fieldOf("resolved").forGetter(SymbolDisplayMetadata::resolved),
Codec.STRING.optionalFieldOf("poem", "").forGetter(SymbolDisplayMetadata::poem)
).apply(instance, SymbolDisplayMetadata::new));
public static final StreamCodec<ByteBuf, SymbolDisplayMetadata> STREAM_CODEC = StreamCodec.composite(
Identifier.STREAM_CODEC, SymbolDisplayMetadata::id,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::nameTranslationKey,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::descriptionTranslationKey,
Identifier.STREAM_CODEC, SymbolDisplayMetadata::category,
ByteBufCodecs.BOOL, SymbolDisplayMetadata::resolved,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::poem,
SymbolDisplayMetadata::new
);
public static SymbolDisplayMetadata unresolved(SymbolReference reference) {
return new SymbolDisplayMetadata(
reference.id(),
"symbol.rivencraft.unresolved",
"symbol.rivencraft.unresolved.description",
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "unresolved"),
false,
""
);
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import io.netty.buffer.ByteBuf;
import java.util.Objects;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
/** Stable namespaced identity for one writing symbol. */
public record SymbolReference(Identifier id) {
public static final Codec<SymbolReference> CODEC = Identifier.CODEC.xmap(
SymbolReference::new, SymbolReference::id);
public static final StreamCodec<ByteBuf, SymbolReference> STREAM_CODEC = Identifier.STREAM_CODEC.map(
SymbolReference::new, SymbolReference::id);
public SymbolReference {
Objects.requireNonNull(id, "id");
}
/** Remains useful when the defining data pack has been removed. */
public String unresolvedName() {
return id.toString();
}
}
@@ -0,0 +1,64 @@
package net.dnorberg.rivencraft.writing;
import net.dnorberg.rivencraft.Rivencraft;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
/** A bounded, server-derived view of symbols physically present in the desk. */
public final class WritingDeskCatalog {
private static final int MAX_ENTRIES = WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES;
private WritingDeskCatalog() {}
public static List<SymbolDisplayMetadata> available(WritingDeskBlockEntity desk) {
Map<Identifier, SymbolDisplayMetadata> unique = new HashMap<>();
for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) {
ItemStack collection = desk.getItem(slot);
if (collection.is(Rivencraft.PAGE.get())) add(unique, collection);
if (CollationFolderItem.isCollection(collection)) {
List<ItemStack> pages = collection.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
pages.forEach(page -> add(unique, page));
}
}
return unique.values().stream()
.sorted(Comparator.comparing((SymbolDisplayMetadata symbol) -> symbol.category().toString())
.thenComparing(symbol -> symbol.id().toString()))
.toList();
}
private static void add(Map<Identifier, SymbolDisplayMetadata> unique, ItemStack page) {
if (!page.is(Rivencraft.PAGE.get())) return;
PageContent content = PageItem.content(page).resolve();
if (content.kind() != PageContent.Kind.SYMBOL) return;
SymbolReference reference = content.symbol().orElseThrow();
var entry = PortSymbolPages.find(reference.id());
if (entry != null) unique.put(reference.id(), new SymbolDisplayMetadata(entry.id(),
entry.nameKey(), entry.descriptionKey(), Identifier.parse(entry.category()), true, entry.poem()));
}
public static void write(RegistryFriendlyByteBuf buffer, List<SymbolDisplayMetadata> entries) {
if (entries.size() > MAX_ENTRIES) throw new IllegalArgumentException("Desk symbol catalog exceeds limit");
buffer.writeVarInt(entries.size());
entries.forEach(entry -> SymbolDisplayMetadata.STREAM_CODEC.encode(buffer, entry));
}
public static List<SymbolDisplayMetadata> read(RegistryFriendlyByteBuf buffer) {
int count = buffer.readVarInt();
if (count < 0 || count > MAX_ENTRIES) throw new IllegalArgumentException("Invalid desk symbol count " + count);
List<SymbolDisplayMetadata> entries = new ArrayList<>(count);
for (int index = 0; index < count; index++) {
entries.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer));
}
return List.copyOf(entries);
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/collation_folder"}}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/notebook"}}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/sealed_notebook"}}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/symbol_portfolio"}}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/writing_desk"}}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_material": {"trigger":"minecraft:inventory_changed","conditions":{"items":[{"items":"minecraft:leather"}]}},
"has_the_recipe": {"trigger":"minecraft:recipe_unlocked","conditions":{"recipes":["rivencraft:notebook"]}}
},
"requirements": [["has_material", "has_the_recipe"]],
"rewards": {"recipes":["rivencraft:notebook"]}
}
@@ -0,0 +1,9 @@
{
"parent":"minecraft:recipes/root",
"criteria":{
"has_material":{"trigger":"minecraft:inventory_changed","conditions":{"items":[{"items":"minecraft:feather"}]}},
"has_the_recipe":{"trigger":"minecraft:recipe_unlocked","conditions":{"recipes":["rivencraft:writing_desk"]}}
},
"requirements":[["has_material","has_the_recipe"]],
"rewards":{"recipes":["rivencraft:writing_desk"]}
}
@@ -0,0 +1,7 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"key": {"L": "minecraft:leather", "S": "minecraft:string"},
"pattern": [" L ", "S ", " L "],
"result": {"count": 1, "id": "rivencraft:notebook"}
}
@@ -0,0 +1,6 @@
{
"type":"minecraft:crafting_shaped", "category":"misc",
"pattern":["V F", "PPP", "P P"],
"key":{"V":"minecraft:glass_bottle", "F":"minecraft:feather", "P":"#minecraft:planks"},
"result":{"id":"rivencraft:writing_desk", "count":1}
}