diff --git a/README.md b/README.md index a9aba3a..0bbf48d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dev images/Pasted image.png b/dev images/Pasted image.png deleted file mode 100644 index 6764c5a..0000000 Binary files a/dev images/Pasted image.png and /dev/null differ diff --git a/dev images/current 26.3.png b/dev images/current 26.3.png new file mode 100644 index 0000000..69dc156 Binary files /dev/null and b/dev images/current 26.3.png differ diff --git a/docs/MINECRAFT_26_3_PORT.md b/docs/MINECRAFT_26_3_PORT.md index 2c636a8..987b690 100644 --- a/docs/MINECRAFT_26_3_PORT.md +++ b/docs/MINECRAFT_26_3_PORT.md @@ -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, diff --git a/ports/mc263/README.md b/ports/mc263/README.md index fcacc4b..e4e4eea 100644 --- a/ports/mc263/README.md +++ b/ports/mc263/README.md @@ -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 ` and +`/rivencraft_port age platform_probe ` 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 ` and `age dropped_probe `. + +### 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 diff --git a/ports/mc263/RELEASE_NOTES_1.0.1.106-beta.1.md b/ports/mc263/RELEASE_NOTES_1.0.1.106-beta.1.md new file mode 100644 index 0000000..b63773a --- /dev/null +++ b/ports/mc263/RELEASE_NOTES_1.0.1.106-beta.1.md @@ -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). diff --git a/ports/mc263/build.gradle b/ports/mc263/build.gradle index 1fa9860..6b40c08 100644 --- a/ports/mc263/build.gradle +++ b/ports/mc263/build.gradle @@ -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' diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties index 53fff0b..69d253c 100644 --- a/ports/mc263/gradle.properties +++ b/ports/mc263/gradle.properties @@ -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 diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java index ec1b411..5f5aa48 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java @@ -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 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); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 6f29fe3..0cbc848 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -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> ENTITIES = + DeferredRegister.create(Registries.ENTITY_TYPE, MOD_ID); + public static final Supplier> + DROPPED_BOOK = ENTITIES.register("dropped_book", () -> net.minecraft.world.entity.EntityType.Builder + .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 SOUNDS = DeferredRegister.create(Registries.SOUND_EVENT, MOD_ID); public static final Supplier LINK = sound("linking.link"); @@ -82,6 +91,10 @@ public final class Rivencraft { public static final Supplier> AGE_BOOK_ID = COMPONENTS.registerComponentType( "age_book_id", builder -> builder.persistent(Identifier.CODEC) .networkSynchronized(Identifier.STREAM_CODEC)); + public static final Supplier> 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> 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 CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block", Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)); + public static final DeferredBlock 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 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> INK_MIXER_MENU = MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new)); + public static final DeferredBlock WRITING_DESK = + BLOCKS.registerBlock("writing_desk", net.dnorberg.rivencraft.block.WritingDeskBlock::new, + () -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()); + public static final DeferredItem WRITING_DESK_ITEM = ITEMS.registerSimpleBlockItem(WRITING_DESK); + public static final Supplier> 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> WRITING_DESK_MENU = + MENUS.register("writing_desk", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.WritingDeskMenu::new)); + public static final Supplier> NOTEBOOK_MENU = + MENUS.register("notebook", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.NotebookMenu::new)); + public static final DeferredItem NOTEBOOK = + ITEMS.registerItem("notebook", net.dnorberg.rivencraft.item.NotebookItem::new, properties -> properties.stacksTo(1)); + public static final DeferredItem COLLATION_FOLDER = + ITEMS.registerItem("collation_folder", net.dnorberg.rivencraft.item.CollationFolderItem::new, properties -> properties.stacksTo(1)); + public static final DeferredItem SYMBOL_PORTFOLIO = + ITEMS.registerItem("symbol_portfolio", net.dnorberg.rivencraft.item.SymbolPortfolioItem::new, properties -> properties.stacksTo(1)); + public static final DeferredItem SEALED_NOTEBOOK = + ITEMS.registerItem("sealed_notebook", net.dnorberg.rivencraft.item.SealedNotebookItem::new, properties -> properties.stacksTo(16)); public static final DeferredItem CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK); public static final DeferredBlock 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); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java index d4c422f..32afcc2 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java @@ -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 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 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 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 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 context) { int count = IntegerArgumentType.getInteger(context, "pages"); var pages = new java.util.ArrayList(); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java index 87a91c5..105f39d 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java @@ -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); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeVisits.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeVisits.java new file mode 100644 index 0000000..8fbc63a --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeVisits.java @@ -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 CODEC = Identifier.CODEC.listOf().fieldOf("ages").codec() + .xmap(PortAgeVisits::new, visits -> visits.ages.stream().sorted().toList()); + static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath("rivencraft", "age_visits"), + () -> new PortAgeVisits(List.of()), CODEC); + private final Set ages; + + PortAgeVisits(List 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; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBinderCollectionProbe.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBinderCollectionProbe.java new file mode 100644 index 0000000..6dc4f66 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBinderCollectionProbe.java @@ -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 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 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"); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortCollectionExtrasProbe.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortCollectionExtrasProbe.java new file mode 100644 index 0000000..5659e8a --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortCollectionExtrasProbe.java @@ -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 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(); + 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)); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDroppedBookProbe.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDroppedBookProbe.java new file mode 100644 index 0000000..0230273 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDroppedBookProbe.java @@ -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 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"); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortNotebookProbe.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortNotebookProbe.java new file mode 100644 index 0000000..de9e5d5 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortNotebookProbe.java @@ -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); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortReturnLinks.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortReturnLinks.java new file mode 100644 index 0000000..8489105 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortReturnLinks.java @@ -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; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortWritingDeskProbe.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortWritingDeskProbe.java new file mode 100644 index 0000000..bbd0028 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortWritingDeskProbe.java @@ -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"); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/CloudPlatformBlock.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/CloudPlatformBlock.java new file mode 100644 index 0000000..60cd744 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/CloudPlatformBlock.java @@ -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; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/WritingDeskBlock.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/WritingDeskBlock.java new file mode 100644 index 0000000..41b0c68 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/WritingDeskBlock.java @@ -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 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 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); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/PortBookBinderBlockEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/PortBookBinderBlockEntity.java index 1ecc950..8791091 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/PortBookBinderBlockEntity.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/PortBookBinderBlockEntity.java @@ -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 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; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java new file mode 100644 index 0000000..6c948e8 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java @@ -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 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 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); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java index b2e4d11..19e85ef 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java @@ -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()); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/ClientWritingDeskCatalog.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/ClientWritingDeskCatalog.java new file mode 100644 index 0000000..ba82635 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/ClientWritingDeskCatalog.java @@ -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()); + } + }); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/DroppedBookRenderer.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/DroppedBookRenderer.java new file mode 100644 index 0000000..32f1c5c --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/DroppedBookRenderer.java @@ -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 { + 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); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/NotebookScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/NotebookScreen.java new file mode 100644 index 0000000..a67be90 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/NotebookScreen.java @@ -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 { + 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); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java index 016e7f1..a7d85dd 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java @@ -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 diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/WritingDeskRenderer.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/WritingDeskRenderer.java new file mode 100644 index 0000000..d9ab4ee --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/WritingDeskRenderer.java @@ -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 { + 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; } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/WritingDeskScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/WritingDeskScreen.java new file mode 100644 index 0000000..a18b9db --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/WritingDeskScreen.java @@ -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 { + 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 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 filteredPages() { + String query = search == null ? "" : search.getValue().trim().toLowerCase(Locale.ROOT); + List entries = new ArrayList<>(); + int from = allMode ? 0 : menu.activeCollection(); + int until = allMode ? WritingDeskBlockEntity.COLLECTIONS : from + 1; + for (int collection = from; collection < until; collection++) { + List 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 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 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 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 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 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 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); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/entity/DroppedBookEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/entity/DroppedBookEntity.java new file mode 100644 index 0000000..84df26b --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/entity/DroppedBookEntity.java @@ -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 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); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/CollationFolderItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/CollationFolderItem.java new file mode 100644 index 0000000..eafb0f4 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/CollationFolderItem.java @@ -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; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/NotebookItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/NotebookItem.java new file mode 100644 index 0000000..542e25a --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/NotebookItem.java @@ -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 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)); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index 23fb881..0daf46c 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -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); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PortHandheldTravel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PortHandheldTravel.java new file mode 100644 index 0000000..6e9f2ad --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PortHandheldTravel.java @@ -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 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(); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/SealedNotebookItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/SealedNotebookItem.java new file mode 100644 index 0000000..91952a8 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/SealedNotebookItem.java @@ -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(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; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/SymbolPortfolioItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/SymbolPortfolioItem.java new file mode 100644 index 0000000..fa030d0 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/SymbolPortfolioItem.java @@ -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 createVolumes(Identifier category, List pages) { + if (pages.isEmpty()) return List.of(); + int capacity = NotebookContents.MAX_PAGES; + int volumeCount = (pages.size() + capacity - 1) / capacity; + List 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); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/NotebookMenu.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/NotebookMenu.java new file mode 100644 index 0000000..de190e3 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/NotebookMenu.java @@ -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 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 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)); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/PortBookBinderMenu.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/PortBookBinderMenu.java index a9200f0..05751f1 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/PortBookBinderMenu.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/PortBookBinderMenu.java @@ -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); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/WritingDeskMenu.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/WritingDeskMenu.java new file mode 100644 index 0000000..8744631 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/WritingDeskMenu.java @@ -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 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 catalog() { return catalog; } + public void setCatalog(List 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 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 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 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; } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java index 912a499..4f239a6 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java @@ -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) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/WritingDeskCatalogPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/WritingDeskCatalogPayload.java new file mode 100644 index 0000000..6cf77dc --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/WritingDeskCatalogPayload.java @@ -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 symbols) + implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "writing_desk_catalog")); + public static final StreamCodec 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 type() { return TYPE; } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index fd17698..4730094 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -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 creativeSymbolPortfolios( + net.minecraft.core.HolderLookup.Provider registries) { + var portfolios = new java.util.ArrayList(); + 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); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/NotebookContents.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/NotebookContents.java new file mode 100644 index 0000000..87bdf07 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/NotebookContents.java @@ -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 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 CODEC = ItemStack.OPTIONAL_CODEC.listOf() + .validate(NotebookContents::validate) + .xmap(NotebookContents::new, NotebookContents::pages); + public static final StreamCodec 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 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> validate(List pages) { + if (pages.size() > MAX_PAGES) { + return DataResult.error(() -> "Notebook exceeds " + MAX_PAGES + " page positions"); + } + for (ItemStack page : pages) { + DataResult result = validatePage(page); + if (result.error().isPresent()) { + return DataResult.error(() -> result.error().orElseThrow().message()); + } + } + return DataResult.success(pages); + } + + private static DataResult 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 pages = new ArrayList<>(size); + for (int index = 0; index < size; index++) { + pages.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(buffer)); + } + return new NotebookContents(pages); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PageContent.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PageContent.java new file mode 100644 index 0000000..4fd77b1 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PageContent.java @@ -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 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_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 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 CODEC = RECORD_CODEC.flatXmap(PageContent::validate, PageContent::validate); + public static final StreamCodec 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 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 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 + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java index 14ea07b..c687e7c 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java @@ -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(); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolNumerals.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolNumerals.java index 7449d60..0926a92 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolNumerals.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolNumerals.java @@ -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}; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java index da7ef80..d30a6a6 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java @@ -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 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 pages(String category, HolderLookup.Provider registries) { List pages = new ArrayList<>(); ENTRIES.values().stream().filter(entry -> entry.category().equals(category)) diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/SymbolDisplayMetadata.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/SymbolDisplayMetadata.java new file mode 100644 index 0000000..46d4092 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/SymbolDisplayMetadata.java @@ -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 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 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, + "" + ); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/SymbolReference.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/SymbolReference.java new file mode 100644 index 0000000..0418062 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/SymbolReference.java @@ -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 CODEC = Identifier.CODEC.xmap( + SymbolReference::new, SymbolReference::id); + public static final StreamCodec 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(); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/WritingDeskCatalog.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/WritingDeskCatalog.java new file mode 100644 index 0000000..a3ac0f0 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/WritingDeskCatalog.java @@ -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 available(WritingDeskBlockEntity desk) { + Map 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 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 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 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 read(RegistryFriendlyByteBuf buffer) { + int count = buffer.readVarInt(); + if (count < 0 || count > MAX_ENTRIES) throw new IllegalArgumentException("Invalid desk symbol count " + count); + List entries = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + entries.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer)); + } + return List.copyOf(entries); + } +} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/collation_folder.json b/ports/mc263/src/main/resources/assets/rivencraft/items/collation_folder.json new file mode 100644 index 0000000..2e819de --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/collation_folder.json @@ -0,0 +1 @@ +{"model":{"type":"minecraft:model","model":"rivencraft:item/collation_folder"}} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/notebook.json b/ports/mc263/src/main/resources/assets/rivencraft/items/notebook.json new file mode 100644 index 0000000..55590e4 --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/notebook.json @@ -0,0 +1 @@ +{"model":{"type":"minecraft:model","model":"rivencraft:item/notebook"}} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/sealed_notebook.json b/ports/mc263/src/main/resources/assets/rivencraft/items/sealed_notebook.json new file mode 100644 index 0000000..d04d503 --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/sealed_notebook.json @@ -0,0 +1 @@ +{"model":{"type":"minecraft:model","model":"rivencraft:item/sealed_notebook"}} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/symbol_portfolio.json b/ports/mc263/src/main/resources/assets/rivencraft/items/symbol_portfolio.json new file mode 100644 index 0000000..318a8e5 --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/symbol_portfolio.json @@ -0,0 +1 @@ +{"model":{"type":"minecraft:model","model":"rivencraft:item/symbol_portfolio"}} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/writing_desk.json b/ports/mc263/src/main/resources/assets/rivencraft/items/writing_desk.json new file mode 100644 index 0000000..761547e --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/writing_desk.json @@ -0,0 +1 @@ +{"model":{"type":"minecraft:model","model":"rivencraft:item/writing_desk"}} diff --git a/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/notebook.json b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/notebook.json new file mode 100644 index 0000000..a1d33af --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/notebook.json @@ -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"]} +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/writing_desk.json b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/writing_desk.json new file mode 100644 index 0000000..cea8165 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/writing_desk.json @@ -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"]} +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/recipe/notebook.json b/ports/mc263/src/main/resources/data/rivencraft/recipe/notebook.json new file mode 100644 index 0000000..ab1fe11 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/recipe/notebook.json @@ -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"} +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/recipe/writing_desk.json b/ports/mc263/src/main/resources/data/rivencraft/recipe/writing_desk.json new file mode 100644 index 0000000..575726c --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/recipe/writing_desk.json @@ -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} +}