From 35980338e1ee84a5a9a7585569038e44d413feb7 Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Mon, 9 Nov 2026 18:20:40 -0500 Subject: [PATCH] Port lectern stands, book GUIs and progression to 26.3 beta --- README.md | 7 +- docs/MINECRAFT_26_3_PORT.md | 148 ++++++++++++ ports/mc263/README.md | 228 +++++++++++++++++- ports/mc263/RELEASE_NOTES_1.0.1.115-beta.1.md | 88 +++++++ ports/mc263/build.gradle | 6 + ports/mc263/gradle.properties | 2 +- .../net/dnorberg/rivencraft/Rivencraft.java | 11 + .../advancement/ProgressionAdvancements.java | 27 +++ .../rivencraft/age/AgeLevelIndex.java | 18 +- .../rivencraft/age/PortAgeCommands.java | 53 ++++ .../age/PortBinderCollectionProbe.java | 60 ++++- .../age/PortBookDropConversionProbe.java | 110 +++++++++ .../rivencraft/age/PortBookStandProbe.java | 93 +++++++ .../rivencraft/age/PortProgressionPlayer.java | 25 ++ .../rivencraft/age/PortProgressionProbe.java | 101 ++++++++ .../rivencraft/age/PortReturnLinks.java | 4 + .../rivencraft/age/PortWritingDeskProbe.java | 4 +- .../rivencraft/age/RuntimeAgeBackend.java | 12 + .../rivencraft/block/BookStandBlock.java | 33 +++ .../block/entity/BookStandBlockEntity.java | 40 +++ .../rivencraft/client/BookStandRenderer.java | 85 +++++++ .../client/PortBookBinderClientEvents.java | 1 + .../client/PortBookInteractionHandler.java | 15 ++ .../client/PortDescriptiveBookScreen.java | 124 +++++++--- .../client/PortLegacyBookArtwork.java | 70 ++++++ .../client/PortLegacyLinkPanel.java | 76 ++++++ .../client/PortLinkingBookScreen.java | 115 ++++++--- .../rivencraft/entity/DroppedBookEntity.java | 11 + .../event/BookStandConversionHandler.java | 40 +++ .../event/DroppedBookConversionHandler.java | 37 +++ .../rivencraft/item/LinkingBookItem.java | 30 ++- .../rivencraft/item/PortHandheldTravel.java | 1 + .../rivencraft/menu/InkMixerMenu.java | 6 + .../rivencraft/menu/PortBookBinderMenu.java | 3 + .../rivencraft/menu/WritingDeskMenu.java | 6 + .../network/PortBookNetworking.java | 54 ++++- .../network/UseBookStandPayload.java | 18 ++ .../writing/DescriptiveBookPages.java | 21 ++ .../advancement/progression/root.json | 21 ++ .../advancement/recipes/misc/book_binder.json | 34 +++ .../recipes/misc/crystal_block.json | 34 +++ .../advancement/recipes/misc/ink_mixer.json | 34 +++ .../advancement/recipes/misc/ink_vial.json | 34 +++ 43 files changed, 1850 insertions(+), 90 deletions(-) create mode 100644 ports/mc263/RELEASE_NOTES_1.0.1.115-beta.1.md create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/advancement/ProgressionAdvancements.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBookDropConversionProbe.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBookStandProbe.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortProgressionPlayer.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortProgressionProbe.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/block/BookStandBlock.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookStandBlockEntity.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyBookArtwork.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyLinkPanel.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/event/DroppedBookConversionHandler.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseBookStandPayload.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/DescriptiveBookPages.java create mode 100644 ports/mc263/src/main/resources/data/rivencraft/advancement/progression/root.json create mode 100644 ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/book_binder.json create mode 100644 ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/crystal_block.json create mode 100644 ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_mixer.json create mode 100644 ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_vial.json diff --git a/README.md b/README.md index 0bbf48d..c881752 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,13 @@ supports authored terrain/biomes, structure and vegetation pages, independent Age weather/lighting, seeded and colorable Overworld/Nether/End/Static skies, independent Sun/Moon controls, multiple bodies and eclipses, seeded biome and feature completion, and random structure selection. Nether/End noise-biome -materials now follow their native rules. The beta does not yet provide full +materials now follow their native rules. Books now have Legacy-style spreads, +animated fallback panels and saved destination labels; lectern stands, open-book +drops, page collections, stations and basic progression are also ported. +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.106-beta.1.md). +[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.115-beta.1.md). Keep the builds and their saves separate, and back up worlds before updating. ## Getting started diff --git a/docs/MINECRAFT_26_3_PORT.md b/docs/MINECRAFT_26_3_PORT.md index 987b690..1009f32 100644 --- a/docs/MINECRAFT_26_3_PORT.md +++ b/docs/MINECRAFT_26_3_PORT.md @@ -2021,3 +2021,151 @@ passed again after the 26.3 registry slice. On 2026-11-01, the default 1.21.1 GameTest server was rerun and all 93 required tests passed, including the desk test. Issue #97 was then closed; remaining 26.3 parity work stays under #98–#104. + +## Progression advancement port + +`1.0.1.107-beta.1` reuses seven root progression JSON files and existing +translations. The Open Portal node remains excluded because its Receptacle icon +and action are not yet ported. Added server-owned post-commit hooks for Mixer +normal/shift output, Desk normal/shift copies, Binder assembly and successful +handheld travel whose destination is a saved runtime Age. Inventory and root +criteria remain vanilla. Existing saves retain vanilla player advancement data; +no action history is reconstructed and no protocol/schema change is required. + +The build and disposable-server fixtures passed loaded data, preview/empty Mixer +rejection, both Panel pickup paths, actual Desk copy and Binder assembly awards, +and registered-Age entry eligibility. NeoForge FakePlayer uses a no-op advancement +tracker; the fixture player overrides it with vanilla PlayerAdvancements rather +than changing production awards to accommodate a test double. No client joined; +GUI tree/toasts, inventory triggers, actual travel and reconnect persistence are +the user validation. The test server was stopped and its world removed. + +## Recipe discovery and advancement background + +`1.0.1.109-beta.1` uses the existing Crystal Block texture for the root +advancement background via the modern asset ID. The .108 stone-path correction +passed the player's in-game test. Added adapted 1.21.1 discovery advancements +for Ink Vial, Ink Mixer, Book Binder and Crystal Block, retaining all original +materials, OR requirements and recipe rewards. The 26.3 recipe-unlocked trigger +requires `conditions.recipes`, a list. Root 1.21.1 data remains unchanged. + +The build and disposable-server `recipe_discovery_probe` passed loaded +recipe/advancement availability, wrong-material negatives, actual matching-material +inventory triggers, per-player isolation and direct recipe grants for all six +crafting unlocks (including Notebook and Writing Desk). No client joined this +fixture. The test server was stopped and its world removed; crystal background +and client recipe-book presentation remain manual checks. + +## Lectern book stands + +`1.0.1.110-beta.1` adapts the root stand block/entity/conversion and final +renderer transforms to 26.3 extraction/submission. It uses the shared lectern +blockstate and lectern-drop loot table; no new art or stand item. Storage sync +and persistence use ValueInput/ValueOutput. preRemoveSideEffects receives the +old state and handles one stored-item drop; emptying before conversion back to +vanilla prevents duplicate recovery. + +The existing book screens accept a stand source. A separate validated packet +checks dimension, eight-block distance, loaded chunk, stand identity and exact +book components before travel. Descriptive travel now shares the same helper +for handheld/stand routes; only handheld books are detached/dropped. Linking +travel uses the existing item service. Stand travel retains effects, Return Link +configuration and Age-entry awards. Live views and exploration remain unported; +unbound books require removal for handheld binding in this slice. + +Build and isolated server checks passed all facing/hand/book/page combinations, +occupied/unrelated-item rejection, ownership on retrieval/break, stale/distant/ +wrong-dimension requests, serialization and full restart persistence. The test +server was stopped and world removed. GUI/rendering/actual stand travel and +modifier effects require the player's in-game check. Root 1.21.1 is unchanged. + +## Descriptive Book authored-page GUI + +`1.0.1.111-beta.1` adapts the root Legacy cover/front/full-right-page composition +and page-index rules to GuiGraphicsExtractor. Glyphs use the existing glyph-only +special item model in a later stratum at 128×128, avoiding the dynamic-blit and +nested item-outline issues previously fixed in the Binder. Navigation uses SDL +mouse constants and current KeyEvent helpers, corner hotspots and page-turn sound. +The destination packet remains confined to the front spread; stand source +validation is unchanged. Naming widgets stay hidden off the front spread even +after resize. Screens do not pause the world. + +Added the root `book_author` component with persistent string and network codec. +Binder and handheld descriptive binding assign the authenticated player's profile +name; the client never supplies author text. Existing books without it use the +unknown-author translation. Existing book_symbols retain written order, duplicates +and omitted-controller choices exactly; no Age data or generation behavior changes. +Random-Age glyph descriptions/live destination rendering remain unported. + +Build and disposable server checks passed real assembly, authorship, first/last +symbol mapping, repeated-symbol and legacy-empty fallbacks, navigation bounds and +full-restart book author/page persistence. Shared full-page texture was verified +in the JAR. The temporary server was stopped and world removed. Client visuals, +turning/input/sound, lectern opening and panel-click travel remain manual checks. + +## Animated link-panel fallback + +`1.0.1.112-beta.1` reuses the root LegacyDestinationPreviewRenderer spiral/noise +algorithm in a shared 26.3 GUI extractor helper, removing placeholder labels from +both book panels. Shared dimensions/hit-testing retain the existing 132×83 panel +and validated hand/stand travel routes. GUI strata enforce cover -> animation -> +Legacy frame ordering. Descriptive symbol pages do not expose an active panel. +No world/network changes, dependencies, remote audio or chunk loads are introduced. +Default teal is retained until destination metadata/tint is ported; this is a +fallback, not live destination rendering. Build passed; client visual/click, +lectern, scale and page-turn checks remain manual. No server run was required. + +## Linking Book spreads and artwork consolidation + +`1.0.1.113-beta.1` adapts LinkingBookScreen's two spreads, counter, wrapped +left-page details, Return Link title suppression and input/page-turn behavior. +The front retains validated hand/stand travel and the animated fallback; the +final spread has no active destination panel. Vanilla dimension names translate; +Age display-name metadata remains pending and other dimensions fall back to IDs. +Block coordinates use floor-based BlockPos conversion, including negative values. + +Both screens use PortLegacyBookArtwork adapted directly from the root helper, +with shared text/counter/corner routines. The descriptive screen retains its +accepted glyph/author behavior. No networking, persistence or server changes. +Build and diff checks passed; GUI presentation, controls, long names, lecterns +and descriptive-page regression are manual checks. No server was started. + +## Saved destination names + +`1.0.1.114-beta.1` adds optional server-owned Age display metadata and the root +persistent/networked `link_destination_name` item component. Successful book-driven +Age creation saves the entered title; Linking Book binding snapshots its source +Age's title, and automatic Return Links snapshot the departure label. Item tooltip +and GUI use the same component/vanilla/ID fallback. Negative tooltip coordinates +now floor correctly rather than truncating toward zero. + +The index name map defaults empty for old saves and does not participate in seed, +folder or dimension identity selection. Names are visible only for active IDs; +retirement retains metadata for identity/rollback without exposing it as an active +Age. No catalog packet change, remote chunk load, automatic old-label migration +or terrain change is introduced. + +Build and isolated server checks passed actual Binder name assignment, metadata +with case/punctuation, named Linking/automatic Return Link components, legacy +vanilla fallback, unload/reload and full-restart index/book persistence. Temporary +config restored; server stopped and world removed. Client GUI/tooltip/lectern +presentation remains manual. New names require a newly created Age and newly +bound book; old Ages retain their existing fallback label. + +## Ordinary dropped books + +`1.0.1.115-beta.1` implements the 26.3 half of #114. A server-only queued join +conversion creates the custom entity before discarding an ordinary book drop, +preserving toss state and item data through native restoreFrom. It gives the +replacement a new UUID (the original is still registered), adopts open-book +lifetime/decay rules and skips already-custom entities. Canceled replacements +preserve the ordinary source. No force-loading, world scanning or per-tick retry. +Loaded ordinary book entities are also eligible. + +Build and isolated source/state checks passed, including cancellation recovery. +The no-player fixture required one loaded entity chunk for inspection. The user +requested to perform further checks; no additional automated restart test was run. +Server stopped and temporary world removed. Manual checks: Q/inventory toss, +death/Disarm, broken stand, full-inventory delivery/retrieval, open-book visual +orientation, motion, ownership/pickup, component preservation and restart. Root +1.21.1 remains unchanged and #114 stays open until both tracks are complete. diff --git a/ports/mc263/README.md b/ports/mc263/README.md index e4e4eea..782ede6 100644 --- a/ports/mc263/README.md +++ b/ports/mc263/README.md @@ -2,7 +2,7 @@ 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, Ink Mixer, Writing Desk and page collections +their seeds. Books, lectern book stands, 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 @@ -10,7 +10,7 @@ independent Age weather and lighting, multiple authored or seeded celestial bodies and eclipses, biome/material completion, and random features/structures. Instability, crystal portals, remaining discovery/stations, writing recovery and optional integration parity remain unfinished. The latest published beta is -[1.0.1.106-beta.1](RELEASE_NOTES_1.0.1.106-beta.1.md). +[1.0.1.115-beta.1](RELEASE_NOTES_1.0.1.115-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. @@ -1646,6 +1646,34 @@ 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. +### Progression advancements + +The local `1.0.1.107-beta.1` slice reuses the 1.21.1 progression tree and +translations. The root, Page and Linking Book inventory milestones load along +with action milestones for taking a Link Panel from the Ink Mixer, copying a +Page at the Writing Desk, binding an Age in the Book Binder and successfully +travelling by book into a registered Age. Both normal and shift output pickup +paths award after the server consumes the inputs. Opening a menu, viewing a +preview or rejected travel does not earn an action milestone. Awards remain +per-player and use vanilla advancement persistence. No retroactive action +awards are inferred from existing books. The portal advancement is deferred +until the Receptacle/portal feature and its icon item are ported. + +The build and disposable-server checks passed advancement decoding, empty and +preview Mixer negative checks, normal/shift Panel pickup, actual Desk copying, +Binder assembly and registered-Age entry eligibility. Fixtures use vanilla +PlayerAdvancements because NeoForge FakePlayer deliberately discards awards. +The temporary server was stopped and its world deleted. Client tree layout, +toasts, inventory milestones and actual book-travel awards remain manual checks. + +For the in-game check, open Advancements and find the Rivencraft tree. Obtain a +Page and Linking Book; then take a Panel from the Mixer, copy a Page at the Desk, +bind a Descriptive Book and enter its Age. Check that previews alone do not award +progress, then reconnect/restart to confirm earned progress remains. If these +milestones were already earned, revoke the Rivencraft tree with the vanilla +operator advancement command before retesting. Include shift-click output pickup. +Nothing has been committed, pushed or published for this slice. + From the repository root: ```sh @@ -1667,3 +1695,199 @@ The dedicated development server uses `build/run-server`, with its own world and properties. Set a nonconflicting `server-port` there before running if another Minecraft server is active. The prototype restores all indexed Ages during the Overworld load, before initial chunk preparation completes. + +The `1.0.1.108-beta.1` visual correction adapts the advancement root background +to 26.3's ClientAsset resource identifier (`minecraft:gui/advancements/backgrounds/stone`). +The older full texture path decoded but resolved to a missing texture. The shared +1.21.1 root keeps its original path. Check the stone background in the client. + +### Crystal advancement background and recipe discovery + +The local `1.0.1.109-beta.1` slice switches the progression background to the +existing blue Crystal Block texture (`rivencraft:block/crystal_block`) using +the 26.3 ClientAsset identifier format. No new artwork is generated. + +The Ink Vial, Ink Mixer, Book Binder and Crystal Block now receive their 1.21.1 +recipe-book discovery advancements. Black Dye unlocks the vial, a Glass Bottle +unlocks the mixer, an Iron Ingot unlocks the binder, and a Crystal Shard unlocks +the block. As with the already-ported Notebook and Writing Desk, 26.3's +`recipe_unlocked` condition uses a `recipes` list instead of the older `recipe` +field. Recipe ingredients and outputs are unchanged; no Blank Page recipe is added. + +Build and disposable-server checks passed all six loaded crafting unlocks: +wrong material stays locked, obtaining the matching material unlocks the recipe, +progress remains per-player, and granting the recipe directly completes its +advancement. The test server was stopped and its temporary world removed. +The player passed the preceding progression/background correction; this crystal +background and client recipe-book discovery still need an in-game check. Use +Survival and a fresh recipe book (or revoke the relevant recipe/advancement) +when testing the four newly ported unlocks. Nothing is committed or published. + +### Lectern book stands + +The local `1.0.1.110-beta.1` slice ports the 1.21.1 lectern conversion and +book/page display. Place a Rivencraft book or Page on an empty vanilla lectern; +the stand keeps its facing and consumes one item in Survival (Creative retains +its held stack). Vanilla occupied lecterns are not replaced. Shift-right-click +with an empty hand retrieves the stored item and restores a vanilla lectern. +Breaking drops one lectern and its stored item. There is no separate stand item +or new crafting recipe. + +The extracted renderer reuses vanilla lectern geometry, existing book textures +and the final 1.21.1 Page orientation, including east/west yaw corrections. Books +show their name above the stand; Pages have no hover label. Empty-hand use opens +the current port book GUI. Clicking the destination panel travels while leaving +the book on its stand. Disarm/Maintain Momentum, same-dimension Linking Book +restrictions, successful-Age advancements and configured Return Links use the +existing travel logic. Handheld travel still drops the active book at departure. +Unbound books can be displayed; retrieve and bind them in hand for this slice. +Live previews and exploration remain later port work. + +The stand packet includes the source dimension, position and displayed ItemStack. +The server checks distance, loaded source chunk, stand identity and exact current +book contents before travel, rejecting removed/replaced books or stale screens. +No chunk is loaded just to inspect a remote stand. Book storage uses 26.3 +ValueInput/ValueOutput, update tags and packets. The modern block-entity removal +hook receives the old state, so the stored item is dropped there once; retrieval +empties it first to prevent duplicate drops. + +Build and disposable-server checks passed conversion/retrieval for all four +facings, both hands and all three item kinds; occupied-lectern/unrelated-item +rejection; distant/wrong-dimension/replaced/removed-source rejection; exact break +drops; component serialization; and named-book persistence across full restart. +The server was stopped and its world removed. Client checks: display/orientation +in all facings, names above books only, empty-hand GUI opening, panel-click travel +for both bound book types, retention at the departure stand, modifier effects, +Shift retrieval and break drops. Also restart with a loaded stand. Nothing has +been committed, pushed or published for this slice. + +### Descriptive Book author and written pages + +The local `1.0.1.111-beta.1` slice brings the authored-book layout closer to +1.21.1: Legacy gilded cover, front name/author on the left and destination panel +on the right, followed by one authored glyph page at a time on the right. It +reuses `bookui_rpage_full.png` and the existing runtime glyph item pipeline; +no new artwork or per-symbol PNGs are generated. Bottom corners and left/right +arrow keys turn pages with the vanilla page-turn sound. Only the front page's +panel activates travel, including when opened from a stand. Escape still closes +the GUI, and focused naming fields retain text input behavior. + +The server records the player's profile name in the persistent/networked +`book_author` component when binding through the Binder or the temporary handheld +binding flow. Existing books retain their saved ordered `book_symbols`; older +books with no author show the shared unknown-author text. Page zero is the front +spread, followed by each saved symbol in order (including repeated IDs). Books +with no symbols retain the Legacy blank final spread. Inferred generation pages +are not inserted into authored contents. The current destination placeholder +remains; live snapshots and the pageless random-Age glyph description remain +future port work. + +Build and disposable-server checks passed real Binder author assignment, ordered +symbol indexing, repeated-symbol presentation, navigation bounds, older/panel-only +fallback and book author/page persistence across full restart. The server was +stopped and temporary world deleted. Client checks: bind a new book with several +distinct pages and verify your name, cover/layout, glyph size/no extra item outline, +corner/arrow turns, sound, Escape, GUI resize and travel only from the front panel. +Repeat opening the book from a lectern. No commit, push or publication was made. + +### Built-in animated link panels + +The local `1.0.1.112-beta.1` slice ports the 1.21.1 Legacy fallback animation to +both book GUIs. Two noisy teal spirals expand into the panel using the shared +GUI renderer, replacing the static placeholder and instructional text. Rendering +and clicking share the same panel bounds. Separate GUI strata keep the cover, +animation and Legacy frame in order. Descriptive Books display the animation only +on their front page; clicking written pages does not travel. The animation keeps +running while the book is open and does not pause the world, load destination +chunks or render/audio-tick a remote world. Lectern books use the same animation. + +This is the existing mod-independent fallback, not a live snapshot or a new shader. +It currently uses the fallback's default teal tint; destination-specific tint and +live renderer integrations remain unported. The GUI API and outline method were +adapted; the root spiral/noise math, timing and reveal are unchanged. + +The build passed. No server behavior or packet schema changes were made and no +test server was needed. Check animation/reveal/frame in both books, at different +GUI scales and on lecterns; panel clicks should still travel, Escape should close, +and descriptive page turning should keep working. Nothing committed or published. + +### Linking Book final spread and shared artwork + +The local `1.0.1.113-beta.1` slice adapts the remaining Linking Book layout +from 1.21.1. Its front spread uses the plain Legacy cover, animated right panel, +and wrapped/shadow-free text on the left. It shows the bound title, destination +dimension and floored block coordinates. Vanilla dimensions use translated names; +other dimensions currently use their registry IDs until saved Age display-name +metadata is ported. Automatic Return Links suppress the redundant title by its +translation identity, matching root behavior. + +The blank final spread, 0/1 counter, bottom-corner and arrow navigation and +page-turn sound are now present. Binding widgets hide on the final spread and +remain hidden after resizing. Escape closes even with naming focused; focused +text inputs consume inventory/hotbar keys. Only the front panel can travel, +including books opened from a stand. + +Both book screens now share an extractor adaptation of root LegacyBookArtwork, +plus wrapped-text, counter and corner-hit helpers. The descriptive gilded cover, +author and symbol pages are retained. This removes duplicated atlas composition +without changing packets, saved data, Age generation or destination coordinates. +The build passed; no server run was needed for this client-only change. Check +long titles, Overworld/Nether/End destination text and negative coordinates, +front/final turning/sound, resize/Escape/naming, lectern opening and front-only +travel. Recheck descriptive glyph pages after the shared-artwork refactor. +Nothing committed, pushed or published. + +### Saved Age and Linking Book destination names + +The local `1.0.1.114-beta.1` slice ports the root destination-name snapshot. +New Ages save the entered title, preserving case/spaces/punctuation, separately +from their existing dimension ID, folder and seed. New Linking Books bound inside +those Ages store that title in `link_destination_name`; the front GUI and tooltip +share the same name resolution. Vanilla dimensions keep their familiar names. +Automatic Return Links capture the departure dimension's saved name when available. + +The optional display-name map defaults empty for older indexes. Older books/Ages +without metadata retain translated vanilla names or raw dimension IDs; no names +are inferred from inventory books or folder names. This does not rename folders, +rewrite old book labels, change collision suffixes, or reroll any generation. +Labels on existing bound books remain snapshots as in root. Names retained with +retired identities remain unavailable through active-Age lookup and survive a +failed-retirement rollback. Tooltip coordinates now use floor-based BlockPos +conversion, matching the GUI for negative values. + +Build and disposable-server checks passed real Binder creation/name assignment, +case/punctuation preservation, Linking/automatic Return Link labels, older vanilla +fallback, Age unload/reload and full-restart persistence of Age metadata and book +components. The temporary Return Link test config was restored, and the server +was stopped/world removed. Client check: create a new named Age, enter it, bind +a Linking Book there, and inspect its destination in the GUI and tooltip (including +on a stand). Recheck after restart; optional Return Links should show the departure +name when enabled. No commit, push or publication was made. + +### Universal open-book drops (26.3 half of issue #114) + +The local `1.0.1.115-beta.1` slice converts ordinary dropped Linking and +Descriptive Books into the existing open-book entity, including unwritten +variants. A server-only join listener queues one conversion after entity/chunk +registration; it does not scan worlds or force-load chunks. Existing custom +book entities and non-book items are skipped. The source must actually be +registered and still present before conversion, and is discarded only after +replacement succeeds. Rejected replacements leave the ordinary drop intact. + +The factory uses native entity-state copying and preserves ItemStack components, +position, motion, facing, pickup delay, owner/thrower and ordinary entity metadata. +It does not apply the handheld travel constructor's Y offset or zero velocity. +The replacement receives a new UUID so both can coexist during the transaction; +its lifetime switches to the existing open-book decay/weather rules. Existing +ordinary books loaded from disk also use the join hook. No per-tick retry is used +if another mod cancels conversion; those drops are eligible on a later reload. + +Build and disposable-server checks passed state/component/owner copying and +ordinary item, player, container and stand-drop conversion, custom-entity recursion +avoidance, non-book exclusion and cancellation recovery. A no-player fixture's +entity chunk became unavailable to the first query; temporarily loading that +single fixture chunk made the checks pass. No storage stall occurred. The player +offered to perform the remaining validation, so no further automated restart run +was started. Server stopped and world removed. Shared issue #114 remains open: +1.21.1 implementation and manual client validation are pending. Nothing committed +or published. diff --git a/ports/mc263/RELEASE_NOTES_1.0.1.115-beta.1.md b/ports/mc263/RELEASE_NOTES_1.0.1.115-beta.1.md new file mode 100644 index 0000000..3a3e27c --- /dev/null +++ b/ports/mc263/RELEASE_NOTES_1.0.1.115-beta.1.md @@ -0,0 +1,88 @@ +# Rivencraft 26.3 beta 1.0.1.115-beta.1 + +Collects the player-validated port slices .107–.115 since 1.0.1.106-beta.1. +Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. This remains a beta with +incomplete Minecraft 1.21.1 gameplay and optional-integration parity. + +## Book stands and dropped books + +- Place a Linking Book, Descriptive Book or Page on an empty vanilla lectern + to convert it into a Rivencraft book stand, preserving facing. Occupied + vanilla lecterns are left alone. +- Books use the existing open-book model and textures and show their name + above the stand. Pages use their item artwork without a hover label, + retaining the 1.21.1 orientation corrections. +- Open a bound book with an empty hand, then click its destination panel to + travel while leaving the book on its stand. Existing link modifiers, + same-dimension rules, Return Link configuration and Age-entry awards apply. +- Shift-right-click with an empty hand retrieves the stored item and restores + a vanilla lectern. Breaking returns one lectern and its contents. Unwritten + books can be displayed; retrieve them to bind in hand in this beta. +- Ordinary dropped Linking/Descriptive Books now become recoverable open-book + entities, including unwritten books, inventory/player drops, container and + stand drops, death/Disarm drops and ordinary book entities loaded from disk. +- Conversion preserves the stack and components, position, motion, facing, + pickup delay and ownership/thrower metadata. It uses the existing open-book + lifetime, slow decay and water/rain behavior. Already-custom books and + non-book items are skipped. +- Conversion runs after registration without world scans or force-loading. + The original is removed only after replacement succeeds; a canceled + replacement leaves the ordinary item intact and eligible on a later reload. +- Stand travel validates source dimension, distance, loaded chunk and the exact + currently stored book, rejecting stale screens after removal/replacement. + +## Book GUIs and names + +- Descriptive Books reuse the Legacy gilded cover, name/author front spread, + and one authored symbol at a time on following right-hand pages. Saved page + order and repetitions are retained, with runtime glyphs and no extra item outline. +- New Descriptive Books record their author server-side. Older books without + that metadata use the unknown-author label. +- Linking Books use the plain Legacy cover, wrapped title, destination name + and block coordinates, plus the blank final spread. Return Links suppress + the redundant title. Bottom corners or arrow keys turn pages with sound. +- Both GUIs share the Legacy artwork/text/counter/corner helpers. Naming + controls hide on later pages and remain correct after resizing. Escape closes + even with naming focused; normal text input does not trigger inventory keys. +- Ported the existing 1.21.1 fallback's animated teal spiral panel to both GUIs, + including lecterns. Removed placeholder labels. The Legacy frame draws over + the animation; travel is active only on the front panel. +- The animated fallback is not a live destination view. Destination tint, + snapshot renderer integrations and random-Age glyph descriptions remain pending. +- New Ages save their entered display name independently of their ID, folder + and seed. Newly bound Linking Books and automatic Return Links capture their + destination/departure label for both GUI and tooltip. Older Ages/books retain + vanilla-name or dimension-ID fallbacks; existing labels are not rewritten. +- Corrected negative tooltip coordinates to floor consistently with the GUI. + +## Progression and recipe discovery + +- Ported the Rivencraft advancement tree: root, obtaining a Page/Linking Book, + taking a Link Panel from the Ink Mixer, copying a Page, binding an Age and + successfully travelling into an Age. Action awards occur after committed + server actions, including normal and shift pickup; previews earn nothing. +- The advancement background uses the existing blue Crystal Block texture, + with the corrected 26.3 resource-path format. The portal node waits for the + Receptacle/portal port. +- Added recipe-book discovery for Ink Vials (Black Dye), Ink Mixers (Glass + Bottle), Book Binders (Iron Ingot) and Crystal Blocks (Crystal Shard), alongside + the Notebook and Writing Desk unlocks. Three-by-three recipes appear in a + Crafting Table's recipe book, not the inventory's two-by-two book. +- Ingredients and costs are unchanged. Blank Pages remain uncraftable. + +## Validation and remaining work + +- Player in-game checks passed each slice and the final dropped-book changes. +- The 26.3 and independent 1.21.1 builds passed. Gradle test tasks have no + standalone test sources; targeted disposable-server fixtures covered real + station actions, advancements/recipe unlocks, stand ownership/drops and + persistence, book author/page/name persistence, and drop conversion/state. +- Temporary test servers were stopped and their worlds deleted. A no-player + entity fixture required one loaded fixture chunk for inspection; further + visual/restart validation was handed to the player. +- 1.21.1 gameplay code is unchanged. Shared issue #114 remains open for its + matching universal dropped-book fix; Writing Desk model placement stays in #113. +- Instability, full writing recovery, discovery/library loot, crystal portals, + remaining stations/progression and optional integrations still need port work. + +Keep 26.3 and 1.21.1 instances/saves separate and back up beta worlds before updating. diff --git a/ports/mc263/build.gradle b/ports/mc263/build.gradle index 6b40c08..816a7a5 100644 --- a/ports/mc263/build.gradle +++ b/ports/mc263/build.gradle @@ -220,6 +220,9 @@ tasks.named('processResources', ProcessResources).configure { 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 'data/rivencraft/advancement/progression/*.json' + exclude 'data/rivencraft/advancement/progression/open_portal.json' + exclude 'data/rivencraft/advancement/progression/root.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' @@ -231,6 +234,7 @@ tasks.named('processResources', ProcessResources).configure { include 'assets/rivencraft/textures/gui/bookui_cover.png' include 'assets/rivencraft/textures/gui/notebook_pages.png' include 'assets/rivencraft/textures/gui/bookui_pager.png' + include 'assets/rivencraft/textures/gui/bookui_rpage_full.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' @@ -249,6 +253,8 @@ tasks.named('processResources', ProcessResources).configure { include 'assets/rivencraft/models/block/book_binder.json' include 'assets/rivencraft/models/item/book_binder.json' include 'assets/rivencraft/blockstates/book_binder.json' + include 'assets/rivencraft/blockstates/book_stand.json' + include 'data/rivencraft/loot_table/blocks/book_stand.json' include 'data/rivencraft/loot_table/blocks/book_binder.json' include 'assets/rivencraft/models/block/crystal_block.json' include 'assets/rivencraft/blockstates/crystal_block.json' diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties index 69d253c..c4f3936 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.106-beta.1 +mod_version=1.0.1.115-beta.1 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 0cbc848..7c843c3 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -95,6 +95,8 @@ public final class Rivencraft { 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_DESTINATION_NAME = COMPONENTS.registerComponentType( + "link_destination_name", builder -> builder.persistent(Codec.STRING).networkSynchronized(ByteBufCodecs.STRING_UTF8)); public static final Supplier> LINK_BOOK_TARGET = COMPONENTS.registerComponentType( "link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC) .networkSynchronized(LinkBookTarget.STREAM_CODEC)); @@ -106,6 +108,8 @@ public final class Rivencraft { "symbol_page_id", builder -> builder.persistent(Identifier.CODEC) .networkSynchronized(Identifier.STREAM_CODEC)); /** Ordered authored page IDs remain on the finished book for later port slices. */ + public static final Supplier> BOOK_AUTHOR = COMPONENTS.registerComponentType( + "book_author", builder -> builder.persistent(Codec.STRING).networkSynchronized(ByteBufCodecs.STRING_UTF8)); public static final Supplier>> BOOK_SYMBOLS = COMPONENTS.registerComponentType( "book_symbols", builder -> builder.persistent(Identifier.CODEC.listOf()) .networkSynchronized(ByteBufCodecs.list(252) @@ -133,6 +137,12 @@ public final class Rivencraft { public static final Supplier> STAR_FISSURE_BLOCK_ENTITY = BLOCK_ENTITY_TYPES.register("star_fissure", () -> new BlockEntityType<>( StarFissureBlockEntity::new, STAR_FISSURE.get())); + public static final DeferredBlock BOOK_STAND = + BLOCKS.registerBlock("book_stand", net.dnorberg.rivencraft.block.BookStandBlock::new, + () -> BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN)); + public static final Supplier> BOOK_STAND_BLOCK_ENTITY = + BLOCK_ENTITY_TYPES.register("book_stand", () -> new BlockEntityType<>( + net.dnorberg.rivencraft.block.entity.BookStandBlockEntity::new, BOOK_STAND.get())); public static final DeferredBlock BOOK_BINDER = BLOCKS.registerBlock( "book_binder", PortBookBinderBlock::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()); @@ -219,6 +229,7 @@ public final class Rivencraft { NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre); NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost); NeoForge.EVENT_BUS.addListener(PortAgeCommands::register); + NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.event.BookStandConversionHandler::onRightClickBlock); NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin); NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout); NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/advancement/ProgressionAdvancements.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/advancement/ProgressionAdvancements.java new file mode 100644 index 0000000..7cee20b --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/advancement/ProgressionAdvancements.java @@ -0,0 +1,27 @@ +package net.dnorberg.rivencraft.advancement; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.age.RuntimeAgeBackend; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; + +/** Awards action-only progression after the server has committed the action. */ +public final class ProgressionAdvancements { + public static final String LINK_PANEL = "link_panel"; + public static final String COPY_PAGE = "copy_page"; + public static final String BIND_AGE = "bind_age"; + public static final String ENTER_AGE = "enter_age"; + + private ProgressionAdvancements() {} + + public static void award(ServerPlayer player, String path) { + var advancement = player.level().getServer().getAdvancements().get( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path)); + if (advancement != null) player.getAdvancements().award(advancement, "completed"); + } + + public static void awardAgeTravel(ServerPlayer player) { + if (RuntimeAgeBackend.savedAges(player.level().getServer()).containsKey(player.level().dimension().identifier())) + award(player, ENTER_AGE); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java index a3744a2..2e1d233 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java @@ -42,7 +42,9 @@ final class AgeLevelIndex extends SavedData { Codec.unboundedMap(Identifier.CODEC, PortAgeColors.CODEC) .optionalFieldOf("colors", Map.of()).forGetter(index -> index.colors), Codec.unboundedMap(Identifier.CODEC, PortDayCycle.CODEC) - .optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles) + .optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles), + Codec.unboundedMap(Identifier.CODEC, Codec.STRING) + .optionalFieldOf("display_names", Map.of()).forGetter(index -> index.displayNames) ).apply(instance, AgeLevelIndex::new)); static final SavedDataType TYPE = new SavedDataType<>( @@ -65,6 +67,7 @@ final class AgeLevelIndex extends SavedData { private final Map sky; private final Map colors; private final Map dayCycles; + private final Map displayNames; private AgeLevelIndex() { this.ages = new LinkedHashMap<>(); @@ -81,6 +84,7 @@ final class AgeLevelIndex extends SavedData { this.sky = new LinkedHashMap<>(); this.colors = new LinkedHashMap<>(); this.dayCycles = new LinkedHashMap<>(); + this.displayNames = new LinkedHashMap<>(); } private AgeLevelIndex(Map ages, List retired, @@ -94,7 +98,7 @@ final class AgeLevelIndex extends SavedData { Map terrainFeatures, Map lighting, Map sky, Map colors, - Map dayCycles) { + Map dayCycles, Map displayNames) { this.ages = new LinkedHashMap<>(ages); this.retired = new LinkedHashSet<>(retired); this.biomeOverrides = new LinkedHashMap<>(biomeOverrides); @@ -109,6 +113,16 @@ final class AgeLevelIndex extends SavedData { this.sky = new LinkedHashMap<>(sky); this.colors = new LinkedHashMap<>(colors); this.dayCycles = new LinkedHashMap<>(dayCycles); + this.displayNames = new LinkedHashMap<>(displayNames); + } + + String displayName(Identifier id) { return ages.containsKey(id) ? displayNames.get(id) : null; } + + void setDisplayName(Identifier id, String name) { + if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id); + if (name == null || name.isBlank() || name.length() > 32) throw new IllegalArgumentException("Invalid Age display name"); + displayNames.put(id, name.strip()); + setDirty(); } Map entries() { 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 32afcc2..9a0433c 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 @@ -35,6 +35,12 @@ public final class PortAgeCommands { public static void register(RegisterCommandsEvent event) { event.getDispatcher().register(Commands.literal("rivencraft_port") .requires(Commands.hasPermission(Commands.LEVEL_ADMINS)) + .then(Commands.literal("recipe_discovery_probe") + .executes(context -> { + PortProgressionProbe.recipeDiscovery(context.getSource().getServer().overworld()); + context.getSource().sendSuccess(() -> Component.literal("Recipe discovery checks passed for all six crafting unlocks"), false); + return 1; + })) .then(Commands.literal("compat") .executes(context -> { StringBuilder mods = new StringBuilder("Optional mods: "); @@ -69,6 +75,44 @@ public final class PortAgeCommands { return 1; }))) .then(Commands.literal("age") + .then(Commands.literal("create_drop_conversion_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); + PortBookDropConversionProbe.create(level); + context.getSource().sendSuccess(() -> Component.literal("Drop sources queued; run drop_conversion_probe after server tasks complete"), false); + return 1; + })))) + .then(Commands.literal("drop_conversion_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"); + PortBookDropConversionProbe.verify(level); + context.getSource().sendSuccess(() -> Component.literal("Universal book drop conversion/ownership checks passed"), false); + return 1; + }))) + .then(Commands.literal("create_book_stand_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); + PortBookStandProbe.create(level); + context.getSource().sendSuccess(() -> Component.literal("Book Stand conversion/ownership/source checks passed"), false); + return 1; + })))) + .then(Commands.literal("book_stand_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"); + PortBookStandProbe.verify(level); + context.getSource().sendSuccess(() -> Component.literal("Book Stand persistence passed"), false); + return 1; + }))) .then(Commands.literal("create_writing_desk_fixture") .then(Commands.argument("name", StringArgumentType.word()) .then(Commands.argument("seed", LongArgumentType.longArg()) @@ -79,6 +123,15 @@ public final class PortAgeCommands { context.getSource().sendSuccess(() -> Component.literal("Writing Desk transaction/removal checks passed"), false); return 1; })))) + .then(Commands.literal("progression_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"); + PortProgressionProbe.run(level); + context.getSource().sendSuccess(() -> Component.literal("Progression data/action checks passed"), false); + return 1; + }))) .then(Commands.literal("writing_desk_probe") .then(Commands.argument("name", StringArgumentType.word()) .executes(context -> { 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 index 6dc4f66..866c55d 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBinderCollectionProbe.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBinderCollectionProbe.java @@ -52,7 +52,7 @@ final class PortBinderCollectionProbe { 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, + var player = new PortProgressionPlayer(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); @@ -81,6 +81,52 @@ final class PortBinderCollectionProbe { || !binder.getItem(PortBookBinderBlockEntity.COVER).isEmpty() || !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty()) throw new IllegalStateException("Collection-backed Binder assembly regression"); + if (!PortProgressionProbe.isComplete(player, "bind_age")) + throw new IllegalStateException("Missing bind_age award"); + if (!player.getGameProfile().name().equals(book.get(Rivencraft.BOOK_AUTHOR.get()))) + throw new IllegalStateException("Binder author was not server assigned"); + var pages = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(book); + if (pages.lastPage() != EXPECTED.size() || pages.symbolAt(0).isPresent() + || !pages.symbolAt(1).orElseThrow().equals(EXPECTED.getFirst()) + || !pages.symbolAt(EXPECTED.size()).orElseThrow().equals(EXPECTED.getLast()) + || pages.symbolAt(EXPECTED.size() + 1).isPresent() + || pages.turn(0, -1) != 0 || pages.turn(pages.lastPage(), 1) != pages.lastPage()) + throw new IllegalStateException("Authored book page indexing failed"); + var old = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get())); + if (old.lastPage() != 1 || !old.author().isEmpty() || old.symbolAt(1).isPresent()) + throw new IllegalStateException("Older panel-only book fallback failed"); + var repeated = new net.dnorberg.rivencraft.writing.DescriptiveBookPages( + List.of(EXPECTED.getFirst(), EXPECTED.getFirst()), "Test"); + if (repeated.lastPage() != 2 || !repeated.symbolAt(1).equals(repeated.symbolAt(2))) + throw new IllegalStateException("Repeated symbol display was collapsed"); + var ageId = book.get(Rivencraft.AGE_BOOK_ID.get()); + var server = level.getServer(); + if (!RuntimeAgeBackend.savedAgeName(server, ageId).orElseThrow().equals("Collection Fixture104")) + throw new IllegalStateException("Created Age lost its display name"); + var namedLevel = server.getLevel(net.minecraft.resources.ResourceKey.create( + net.minecraft.core.registries.Registries.DIMENSION, ageId)); + if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(namedLevel).equals("Collection Fixture104")) + throw new IllegalStateException("Link binding name lookup did not use Age title"); + RuntimeAgeBackend.setAgeDisplayName(server, ageId, "Crystal Garden's Dawn"); + var target = new net.dnorberg.rivencraft.item.LinkBookTarget(ageId, -0.5, 64, -1.25, 90, 0); + var link = new ItemStack(Rivencraft.LINKING_BOOK.get()); + link.set(Rivencraft.LINK_BOOK_TARGET.get(), target); + link.set(Rivencraft.LINK_DESTINATION_NAME.get(), net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(namedLevel)); + if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(link, target).getString().equals("Crystal Garden's Dawn")) + throw new IllegalStateException("Link destination label ignored saved name"); + var mode = net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.get(); + player.getInventory().clearContent(); + try { + net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.set(net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ALWAYS); + PortReturnLinks.afterSuccessfulTravel(player, ageId, target); + var returned = java.util.stream.IntStream.range(0, player.getInventory().getContainerSize()) + .mapToObj(i -> player.getInventory().getItem(i)).filter(i -> i.is(Rivencraft.LINKING_BOOK.get())) + .findFirst().orElseThrow(); + if (!"Crystal Garden's Dawn".equals(returned.get(Rivencraft.LINK_DESTINATION_NAME.get()))) + throw new IllegalStateException("Automatic Return Link lost departure name"); + chest.setItem(4, returned.copy()); + } finally { net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.set(mode); } + chest.setItem(3, link); chest.setItem(2, book.copy()); menu.setCarried(ItemStack.EMPTY); } finally { player.getInventory().clearContent(); } @@ -93,6 +139,18 @@ final class PortBinderCollectionProbe { 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 (!"RivenBinderProbe".equals(chest.getItem(2).get(Rivencraft.BOOK_AUTHOR.get()))) + throw new IllegalStateException("Authored book lost its author"); + var ageId = chest.getItem(2).get(Rivencraft.AGE_BOOK_ID.get()); + if (!RuntimeAgeBackend.savedAgeName(level.getServer(), ageId).orElseThrow().equals("Crystal Garden's Dawn") + || !"Crystal Garden's Dawn".equals(chest.getItem(3).get(Rivencraft.LINK_DESTINATION_NAME.get())) + || !"Crystal Garden's Dawn".equals(chest.getItem(4).get(Rivencraft.LINK_DESTINATION_NAME.get()))) + throw new IllegalStateException("Age/Linking/Return destination names did not survive persistence"); + var old = new ItemStack(Rivencraft.LINKING_BOOK.get()); + var overworld = new net.dnorberg.rivencraft.item.LinkBookTarget(net.minecraft.world.level.Level.OVERWORLD.identifier(), 0, 64, 0, 0, 0); + if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(old, overworld).getString() + .equals(net.minecraft.network.chat.Component.translatable("dimension.minecraft.overworld").getString())) + throw new IllegalStateException("Older book vanilla name fallback changed"); 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/PortBookDropConversionProbe.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBookDropConversionProbe.java new file mode 100644 index 0000000..ccd492c --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBookDropConversionProbe.java @@ -0,0 +1,110 @@ +package net.dnorberg.rivencraft.age; + +import java.util.List; +import java.util.UUID; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.entity.DroppedBookEntity; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.server.TickTask; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.storage.TagValueOutput; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** Join conversion runs on queued server tasks: create and inspect on separate commands. */ +final class PortBookDropConversionProbe { + private static final UUID OWNER = UUID.fromString("cc5909e5-aafa-43d6-b841-a917ec9c0115"); + private static final AABB AREA = new AABB(-16, -64, -16, 32, 320, 32); + private static ItemStack book(boolean descriptive, String label) { + var stack = new ItemStack(descriptive ? Rivencraft.DESCRIPTIVE_BOOK.get() : Rivencraft.LINKING_BOOK.get()); + stack.set(DataComponents.CUSTOM_NAME, Component.literal(label)); + return stack; + } + static void create(ServerLevel level) { + for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) level.setBlock(new BlockPos(x, 63, z), Blocks.STONE.defaultBlockState(), 2); + var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level, + new com.mojang.authlib.GameProfile(UUID.fromString("84f38604-48fe-48d3-a4fb-f858a7200115"), "RivenDropProbe")); + player.setPos(8.5, 64, 8.5); + // Test exact factory state before physics can advance position or pickup timers. + var sample = new ItemEntity(level, 8.5, 66, 8.5, book(false, "state_copy")); + sample.getItem().set(Rivencraft.LINK_BOOK_TARGET.get(), new net.dnorberg.rivencraft.item.LinkBookTarget( + level.dimension().identifier(), -0.5, 64, -1.25, 135, -20)); + sample.getItem().set(Rivencraft.LINK_DESTINATION_NAME.get(), "Named destination"); + sample.getItem().set(Rivencraft.LINK_PROPERTIES.get(), new net.dnorberg.rivencraft.item.LinkProperties(net.dnorberg.rivencraft.item.LinkProperties.DISARM + | net.dnorberg.rivencraft.item.LinkProperties.MAINTAIN_MOMENTUM)); + sample.setTarget(OWNER); + sample.setThrower(player); + sample.setPickUpDelay(80); + sample.setDeltaMovement(new Vec3(0.2, 0.1, -0.3)); + sample.setYRot(37); + sample.setXRot(12); + sample.setNoGravity(true); + var copy = DroppedBookEntity.fromDrop(sample); + if (!copy.position().equals(sample.position()) || !copy.getDeltaMovement().equals(sample.getDeltaMovement()) + || copy.getYRot() != 37 || copy.getXRot() != 12 || !copy.isNoGravity() + || !ItemStack.matches(copy.getItem(), sample.getItem()) || !OWNER.equals(copy.getTarget()) + || copy.getUUID().equals(sample.getUUID()) || copy.getAge() != -32768) + throw new IllegalStateException("Drop conversion lost placement, ownership or item components"); + var originalTag = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess()); + var copiedTag = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess()); + sample.saveWithoutId(originalTag); copy.saveWithoutId(copiedTag); + for (String key : List.of("PickupDelay", "Owner", "Thrower", "Item")) + if (!java.util.Objects.equals(originalTag.buildResult().get(key), copiedTag.buildResult().get(key))) + throw new IllegalStateException("Drop conversion lost metadata: " + key); + // Four ordinary book variants must all convert through the actual event route. + for (String name : List.of("manual_link", "manual_description", "unwritten_link", "unwritten_description")) { + var stack = book(name.endsWith("description"), name); + if (name.equals("manual_link")) { + stack.set(Rivencraft.LINK_BOOK_TARGET.get(), sample.getItem().get(Rivencraft.LINK_BOOK_TARGET.get())); + stack.set(Rivencraft.LINK_DESTINATION_NAME.get(), "Named destination"); + } + if (name.equals("manual_description")) stack.set(Rivencraft.AGE_BOOK_ID.get(), level.dimension().identifier()); + var item = new ItemEntity(level, 8.5, 66, 8.5, stack); + item.setNoGravity(true); item.setDeltaMovement(Vec3.ZERO); item.setTarget(OWNER); + level.addFreshEntity(item); + } + player.drop(book(false, "player_toss"), false, net.minecraft.util.Prediction.SERVER_ONLY); + net.minecraft.world.Containers.dropItemStack(level, 8.5, 65, 8.5, book(true, "container")); + var standPos = new BlockPos(10, 64, 8); + level.setBlock(standPos, Rivencraft.BOOK_STAND.get().defaultBlockState(), 3); + ((net.dnorberg.rivencraft.block.entity.BookStandBlockEntity) level.getBlockEntity(standPos)).setBook(book(false, "stand")); + level.destroyBlock(standPos, true); + var custom = new DroppedBookEntity(level, new Vec3(8.5, 66, 8.5), book(false, "already_custom")); + custom.setNoGravity(true); + level.addFreshEntity(custom); + level.addFreshEntity(new ItemEntity(level, 8.5, 66, 8.5, net.dnorberg.rivencraft.item.PageItem.createLinkPanelPage())); + java.util.function.Consumer cancel = event -> { + if (event.getLevel() == level && event.getEntity() instanceof DroppedBookEntity dropped + && dropped.getItem().getHoverName().getString().equals("cancel_preserve")) event.setCanceled(true); + }; + net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(cancel); + try { + var item = new ItemEntity(level, 8.5, 66, 8.5, book(false, "cancel_preserve")); + item.setNoGravity(true); item.setDeltaMovement(Vec3.ZERO); + level.addFreshEntity(item); + } finally { + // Queued after that source's conversion task; no fixture listener remains installed. + level.getServer().schedule(new TickTask(0, () -> net.neoforged.neoforge.common.NeoForge.EVENT_BUS.unregister(cancel))); + } + } + static void verify(ServerLevel level) { + var drops = level.getEntitiesOfClass(ItemEntity.class, AREA); + for (String name : List.of("manual_link", "manual_description", "unwritten_link", "unwritten_description", + "player_toss", "container", "stand", "already_custom", "cancel_preserve")) { + var found = drops.stream().filter(e -> e.getItem().getHoverName().getString().equals(name)).toList(); + if (found.size() != 1 || !name.equals("cancel_preserve") && !(found.getFirst() instanceof DroppedBookEntity)) + throw new IllegalStateException("Expected one open book for " + name + ", found " + found.size()); + } + var named = drops.stream().filter(e -> e.getItem().getHoverName().getString().equals("manual_link")).findFirst().orElseThrow(); + if (!OWNER.equals(named.getTarget()) || !"Named destination".equals(named.getItem().get(Rivencraft.LINK_DESTINATION_NAME.get()))) + throw new IllegalStateException("Converted book lost owner or destination"); + if (drops.stream().filter(e -> e.getItem().is(Rivencraft.PAGE.get())).anyMatch(e -> e instanceof DroppedBookEntity)) + throw new IllegalStateException("Non-book item was converted"); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBookStandProbe.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBookStandProbe.java new file mode 100644 index 0000000..43c01c2 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBookStandProbe.java @@ -0,0 +1,93 @@ +package net.dnorberg.rivencraft.age; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.BookStandBlock; +import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity; +import net.dnorberg.rivencraft.event.BookStandConversionHandler; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.network.PortBookNetworking; +import net.dnorberg.rivencraft.network.UseBookStandPayload; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LecternBlock; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; + +/** Isolated conversion, ownership, stale-screen and persistence checks. */ +final class PortBookStandProbe { + private static final BlockPos POS = new BlockPos(0, 64, 0); + static void create(ServerLevel level) { + var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level, + new com.mojang.authlib.GameProfile(java.util.UUID.fromString("a5c323a1-3b58-4dd6-927f-e08000800110"), "RivenStandProbe")); + player.setPos(0.5, 64, 2.5); + try { + for (Direction facing : Direction.Plane.HORIZONTAL) for (InteractionHand hand : InteractionHand.values()) + for (var sample : java.util.List.of(new ItemStack(Rivencraft.LINKING_BOOK.get()), + new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), PageItem.createLinkPanelPage().copyWithCount(3))) { + level.setBlock(POS, Blocks.LECTERN.defaultBlockState().setValue(LecternBlock.FACING, facing), 3); + player.setItemInHand(hand, sample.copy()); + if (!BookStandConversionHandler.convert(player, hand, POS)) throw new IllegalStateException("Conversion failed"); + var stand = (BookStandBlockEntity) level.getBlockEntity(POS); + var stored = sample.copyWithCount(1); + if (!ItemStack.matches(stand.getBook(), stored) || player.getItemInHand(hand).getCount() != sample.getCount() - 1 + || stand.getBlockState().getValue(BookStandBlock.FACING) != facing) + throw new IllegalStateException("Conversion lost ownership or facing"); + var packet = new UseBookStandPayload(level.dimension().identifier(), POS, stored.copy()); + if (PortBookNetworking.validatedStandBook(player, packet).isEmpty()) throw new IllegalStateException("Valid source rejected"); + player.setPos(100, 64, 100); + if (!PortBookNetworking.validatedStandBook(player, packet).isEmpty()) throw new IllegalStateException("Distant source accepted"); + player.setPos(0.5, 64, 2.5); + if (!PortBookNetworking.validatedStandBook(player, new UseBookStandPayload( + net.minecraft.resources.Identifier.fromNamespaceAndPath("minecraft", "overworld"), POS, stored)).isEmpty()) + throw new IllegalStateException("Wrong dimension accepted"); + if (!PortBookNetworking.validatedStandBook(player, new UseBookStandPayload(packet.dimension(), POS, new ItemStack(Items.DIRT))).isEmpty()) + throw new IllegalStateException("Replaced book accepted"); + var loaded = net.minecraft.world.level.block.entity.BlockEntity.loadStatic(POS, stand.getBlockState(), + stand.saveWithFullMetadata(level.registryAccess()), level.registryAccess()); + if (!(loaded instanceof BookStandBlockEntity copy) || !ItemStack.matches(copy.getBook(), stored)) + throw new IllegalStateException("Stand serialization lost content"); + player.getInventory().clearContent(); + player.setShiftKeyDown(true); + stand.getBlockState().useWithoutItem(level, player, new BlockHitResult(Vec3.atCenterOf(POS), Direction.UP, POS, false)); + player.setShiftKeyDown(false); + if (!level.getBlockState(POS).is(Blocks.LECTERN) + || level.getBlockState(POS).getValue(LecternBlock.FACING) != facing + || player.getInventory().countItem(stored.getItem()) != 1 + || !PortBookNetworking.validatedStandBook(player, packet).isEmpty()) + throw new IllegalStateException("Retrieval did not restore lectern or recover item exactly once"); + player.getInventory().clearContent(); + player.setItemInHand(hand, stored.copy()); + BookStandConversionHandler.convert(player, hand, POS); + level.destroyBlock(POS, true); + var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class, + new net.minecraft.world.phys.AABB(-2, 62, -2, 3, 68, 3)); + int items = drops.stream().filter(e -> e.getItem().is(stored.getItem())).mapToInt(e -> e.getItem().getCount()).sum(); + int lecterns = drops.stream().filter(e -> e.getItem().is(Items.LECTERN)).mapToInt(e -> e.getItem().getCount()).sum(); + if (items != 1 || lecterns != 1) throw new IllegalStateException("Break drops duplicated/lost: " + items + "/" + lecterns); + drops.forEach(net.minecraft.world.entity.Entity::discard); + } + level.setBlock(POS, Blocks.LECTERN.defaultBlockState().setValue(LecternBlock.HAS_BOOK, true), 3); + player.setItemInHand(InteractionHand.MAIN_HAND, PageItem.createLinkPanelPage()); + if (BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Occupied lectern converted"); + level.setBlock(POS, Blocks.LECTERN.defaultBlockState(), 3); + player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.DIRT)); + if (BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Unrelated item accepted"); + var named = new ItemStack(Rivencraft.LINKING_BOOK.get()); + named.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal("Stand persistence")); + player.setItemInHand(InteractionHand.MAIN_HAND, named); + if (!BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Final stand missing"); + } finally { player.setShiftKeyDown(false); player.getInventory().clearContent(); } + verify(level); + } + static void verify(ServerLevel level) { + if (!(level.getBlockEntity(POS) instanceof BookStandBlockEntity stand) + || !stand.getBook().is(Rivencraft.LINKING_BOOK.get()) + || !stand.getBook().getHoverName().getString().equals("Stand persistence")) + throw new IllegalStateException("Stand restart persistence failed"); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortProgressionPlayer.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortProgressionPlayer.java new file mode 100644 index 0000000..ed9be71 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortProgressionPlayer.java @@ -0,0 +1,25 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.authlib.GameProfile; +import net.minecraft.server.PlayerAdvancements; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.world.level.storage.LevelResource; +import net.neoforged.neoforge.common.util.FakePlayer; + +/** FakePlayer normally discards all advancement progress; fixtures need vanilla tracking. */ +final class PortProgressionPlayer extends FakePlayer { + private PlayerAdvancements progress; + + PortProgressionPlayer(ServerLevel level, GameProfile profile) { super(level, profile); } + + @Override public PlayerAdvancements getAdvancements() { + if (progress == null) { + var server = level().getServer(); + progress = new PlayerAdvancements(DataFixers.getDataFixer(), server.getPlayerList(), + server.getAdvancements(), server.getWorldPath(LevelResource.PLAYER_ADVANCEMENTS_DIR) + .resolve(getUUID() + ".json"), this); + } + return progress; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortProgressionProbe.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortProgressionProbe.java new file mode 100644 index 0000000..f445451 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortProgressionProbe.java @@ -0,0 +1,101 @@ +package net.dnorberg.rivencraft.age; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.advancement.ProgressionAdvancements; +import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; +import net.dnorberg.rivencraft.menu.InkMixerMenu; +import net.minecraft.core.BlockPos; +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 operator fixture for loaded advancement data and committed action awards. */ +final class PortProgressionProbe { + /** Operator-fixture assertion; deliberately requires the real loaded advancement. */ + static boolean isComplete(net.minecraft.server.level.ServerPlayer player, String path) { + var advancement = player.level().getServer().getAdvancements().get( + net.minecraft.resources.Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path)); + if (advancement == null) throw new IllegalStateException("Missing progression advancement: " + path); + return player.getAdvancements().getOrStartProgress(advancement).isDone(); + } + + + static void recipeDiscovery(ServerLevel level) { + var materials = java.util.Map.of( + "ink_vial", Items.DYE.black(), + "ink_mixer", Items.GLASS_BOTTLE, + "book_binder", Items.IRON_INGOT, + "crystal_block", Rivencraft.CRYSTAL_SHARD.get(), + "notebook", Items.LEATHER, + "writing_desk", Items.FEATHER); + for (var entry : materials.entrySet()) { + var id = net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", entry.getKey()); + var key = net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE, id); + var player = new PortProgressionPlayer(level, new com.mojang.authlib.GameProfile( + java.util.UUID.nameUUIDFromBytes(id.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)), + "RivenRecipe")); + var advancement = level.getServer().getAdvancements().get( + net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", "recipes/misc/" + entry.getKey())); + if (advancement == null || level.getServer().getRecipeManager().byKey(key).isEmpty()) + throw new IllegalStateException("Missing recipe/discovery data: " + id); + var progress = player.getAdvancements(); + net.minecraft.advancements.triggers.CriteriaTriggers.INVENTORY_CHANGED.trigger(player, + player.getInventory(), new ItemStack(Items.COBBLESTONE)); + if (player.getRecipeBook().contains(key)) + throw new IllegalStateException("Wrong material unlocked " + id); + var material = new ItemStack(entry.getValue()); + player.getInventory().setItem(0, material); + net.minecraft.advancements.triggers.CriteriaTriggers.INVENTORY_CHANGED.trigger(player, player.getInventory(), material); + if (!player.getRecipeBook().contains(key) || !progress.getOrStartProgress(advancement).isDone()) + throw new IllegalStateException("Material did not unlock " + id); + // The second criterion supports /recipe give without first obtaining its material. + var other = new PortProgressionPlayer(level, new com.mojang.authlib.GameProfile( + java.util.UUID.randomUUID(), "RivenRecipeGive")); + if (other.getRecipeBook().contains(key)) throw new IllegalStateException("Recipe leaked between players"); + other.awardRecipes(java.util.List.of(level.getServer().getRecipeManager().byKey(key).orElseThrow())); + if (!other.getAdvancements().getOrStartProgress(advancement).isDone()) + throw new IllegalStateException("Recipe unlock criterion did not complete " + id); + } + } + + static void run(ServerLevel level) { + var player = new PortProgressionPlayer(level, + new com.mojang.authlib.GameProfile(java.util.UUID.fromString("b428c3ef-57a6-43dc-95e8-441b6a171107"), "RivenProgression")); + var pos = new BlockPos(4, 64, 0); + player.setPos(4.5, 64, 2.5); + level.setBlock(pos, Rivencraft.INK_MIXER.get().defaultBlockState(), 3); + var mixer = (InkMixerBlockEntity) level.getBlockEntity(pos); + try { + // Every tree node must decode; portal advancement waits for its missing item/feature port. + for (String path : new String[]{"root", "page", "linking_book", "link_panel", "copy_page", "bind_age", "enter_age"}) + if (isComplete(player, path)) + throw new IllegalStateException("Fixture requires fresh player advancement data"); + var menu = new InkMixerMenu(1, player.getInventory(), mixer); + menu.clicked(InkMixerMenu.RESULT, 0, ContainerInput.PICKUP, player); + if (isComplete(player, "link_panel")) + throw new IllegalStateException("Empty Mixer awarded progression"); + mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2)); + mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(Rivencraft.INK_VIAL.get())); + menu.broadcastChanges(); + if (isComplete(player, "link_panel")) + throw new IllegalStateException("Mixer preview awarded progression"); + menu.clicked(InkMixerMenu.RESULT, 0, ContainerInput.PICKUP, player); + if (!isComplete(player, "link_panel")) + throw new IllegalStateException("Mixer pickup did not award progression"); + menu.setCarried(ItemStack.EMPTY); + var advancement = level.getServer().getAdvancements().get( + net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", "progression/link_panel")); + player.getAdvancements().revoke(advancement, "completed"); + menu.quickMoveStack(player, InkMixerMenu.RESULT); + if (!isComplete(player, "link_panel")) + throw new IllegalStateException("Mixer shift pickup did not award progression"); + ProgressionAdvancements.awardAgeTravel(player); + if (!isComplete(player, "enter_age")) + throw new IllegalStateException("Registered Age did not award entry"); + } finally { + player.getInventory().clearContent(); + level.removeBlock(pos, false); + } + } +} 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 index 8489105..4d62aff 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortReturnLinks.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortReturnLinks.java @@ -21,6 +21,10 @@ public final class PortReturnLinks { boolean issue = shouldIssue(mode, storage.computeIfAbsent(PortAgeVisits.TYPE), age); if (!issue) return; ItemStack book = createBook(departure); + var source = player.level().getServer().getLevel(net.minecraft.resources.ResourceKey.create( + net.minecraft.core.registries.Registries.DIMENSION, departure.dimension())); + if (source != null) book.set(Rivencraft.LINK_DESTINATION_NAME.get(), + net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(source)); 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) 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 index bbd0028..e2572c3 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortWritingDeskProbe.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortWritingDeskProbe.java @@ -23,7 +23,7 @@ final class PortWritingDeskProbe { static void create(ServerLevel level) { PortAgeTravel.findLanding(level); - var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level, + var player = new PortProgressionPlayer(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. @@ -69,6 +69,8 @@ final class PortWritingDeskProbe { 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"); + if (!PortProgressionProbe.isComplete(player, "copy_page")) + throw new IllegalStateException("Missing copy_page award"); menu.setCarried(ItemStack.EMPTY); for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64)); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java index 7a2d89a..d1dd298 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java @@ -270,6 +270,18 @@ public final class RuntimeAgeBackend { Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive()); } + /** Display metadata never changes the dimension identity, seed or filesystem path. */ + public static java.util.Optional savedAgeName(MinecraftServer server, Identifier id) { + checkServerThread(server); + return java.util.Optional.ofNullable(index(server).displayName(id)); + } + + public static void setAgeDisplayName(MinecraftServer server, Identifier id, String name) { + checkServerThread(server); + index(server).setDisplayName(id, name); + server.overworld().getDataStorage().saveAndJoin(); + } + public static Map savedAges(MinecraftServer server) { checkServerThread(server); return index(server).entries(); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/BookStandBlock.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/BookStandBlock.java new file mode 100644 index 0000000..47a0b16 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/BookStandBlock.java @@ -0,0 +1,33 @@ +package net.dnorberg.rivencraft.block; + +import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity; +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Prediction; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LecternBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; + +/** Vanilla lectern geometry with Rivencraft storage instead of a vanilla book menu. */ +public final class BookStandBlock extends LecternBlock { + public BookStandBlock(Properties properties) { super(properties); } + @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { return new BookStandBlockEntity(pos, state); } + @Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { + if (!(level.getBlockEntity(pos) instanceof BookStandBlockEntity stand)) return InteractionResult.PASS; + if (player.isShiftKeyDown() && !level.isClientSide()) { + var book = stand.takeBook(); + level.setBlock(pos, Blocks.LECTERN.defaultBlockState().setValue(FACING, state.getValue(FACING)), 3); + if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY); + level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1, 1); + } + return InteractionResult.SUCCESS; + } + @Override protected net.minecraft.world.MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { return null; } + @Override protected int getAnalogOutputSignal(BlockState state, Level level, BlockPos pos, net.minecraft.core.Direction direction) { return 0; } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookStandBlockEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookStandBlockEntity.java new file mode 100644 index 0000000..30f8d2a --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookStandBlockEntity.java @@ -0,0 +1,40 @@ +package net.dnorberg.rivencraft.block.entity; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** One stored book or page, synchronized independently of the player's inventory. */ +public final class BookStandBlockEntity extends BlockEntity { + private ItemStack book = ItemStack.EMPTY; + public BookStandBlockEntity(BlockPos pos, BlockState state) { super(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), pos, state); } + public ItemStack getBook() { return book; } + public void setBook(ItemStack stack) { book = stack; sync(); } + public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; sync(); return result; } + private void sync() { + setChanged(); + if (level != null && !level.isClientSide()) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2); + } + @Override protected void loadAdditional(ValueInput input) { + super.loadAdditional(input); + book = input.read("Book", ItemStack.CODEC).orElse(ItemStack.EMPTY); + } + @Override protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); + if (!book.isEmpty()) output.store("Book", ItemStack.CODEC, book); + } + @Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); } + @Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); } + @Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) { + if (level != null && !level.isClientSide()) + net.minecraft.world.Containers.dropItemStack(level, pos.getX() + 0.5, pos.getY() + 1, pos.getZ() + 0.5, takeBook()); + super.preRemoveSideEffects(pos, oldState); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java new file mode 100644 index 0000000..a2e59ba --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java @@ -0,0 +1,85 @@ +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.block.BookStandBlock; +import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity; +import net.minecraft.client.gui.Font; +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.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.core.Direction; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.ItemDisplayContext; +import net.minecraft.world.phys.Vec3; + +/** Extracted version of the 1.21.1 lectern display, retaining its final page orientation. */ +public final class BookStandRenderer implements BlockEntityRenderer { + 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; + private final ItemModelResolver resolver; + private final Font font; + public BookStandRenderer(BlockEntityRendererProvider.Context context) { + model = new BookModel(context.bakeLayer(ModelLayers.BOOK)); + resolver = context.itemModelResolver(); + font = context.font(); + } + public static final class State extends BlockEntityRenderState { + final ItemStackRenderState page = new ItemStackRenderState(); + boolean present, isPage, descriptive; + float rotation; + Component name; + } + @Override public State createRenderState() { return new State(); } + @Override public void extractRenderState(BookStandBlockEntity stand, State state, float partialTick, + Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) { + BlockEntityRenderer.super.extractRenderState(stand, state, partialTick, camera, overlay); + var book = stand.getBook(); + state.present = !book.isEmpty(); + state.isPage = book.is(Rivencraft.PAGE.get()); + state.descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()); + state.name = state.present && !state.isPage ? book.getHoverName().copy() : null; + Direction facing = stand.getBlockState().getValue(BookStandBlock.FACING); + state.rotation = state.isPage ? facing.toYRot() - 180 + (facing.getAxis() == Direction.Axis.X ? 180 : 0) + : -facing.getClockWise().toYRot(); + resolver.updateForTopItem(state.page, state.isPage ? book : net.minecraft.world.item.ItemStack.EMPTY, + ItemDisplayContext.FIXED, stand.getLevel(), null, 0); + } + @Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + if (!state.present) return; + pose.pushPose(); + pose.translate(0.5F, 1.0625F, 0.5F); + pose.rotateDegrees(Axis.YP, state.rotation); + if (state.isPage) { + pose.rotateDegrees(Axis.XP, 67.5F); + pose.scale(0.7F, 0.7F, 0.7F); + state.page.submit(pose, collector, state.lightCoords, OverlayTexture.NO_OVERLAY, 0); + } else { + pose.rotateDegrees(Axis.ZP, 67.5F); + pose.translate(0, -0.125F, 0); + collector.submitModel(model, BookModel.State.forAnimation(0, 0.1F, 0.9F, 1.2F), pose, + state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY, 0); + } + pose.popPose(); + if (state.name != null) { + pose.pushPose(); + pose.translate(0.5F, 1.6F, 0.5F); + pose.rotate(camera.orientation); + pose.scale(0.025F, -0.025F, 0.025F); + collector.submitText(pose, -font.width(state.name) / 2.0F, 0, state.name.getVisualOrderText(), + false, Font.DisplayMode.NORMAL, state.lightCoords, 0xFFFFFFFF, 0, 0); + pose.popPose(); + } + } +} 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 a7d85dd..a28c3de 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 @@ -20,6 +20,7 @@ public final class PortBookBinderClientEvents { 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); + event.registerBlockEntityRenderer(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), BookStandRenderer::new); } @SubscribeEvent diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java index 62d2196..82c61d7 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java @@ -26,4 +26,19 @@ public final class PortBookInteractionHandler { ? new PortLinkingBookScreen(event.getHand(), event.getItemStack().copy()) : new PortDescriptiveBookScreen(event.getHand(), event.getItemStack().copy())); } + @SubscribeEvent + public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) { + if (!event.getLevel().isClientSide() || event.getEntity().isShiftKeyDown() + || !event.getItemStack().isEmpty() + || !(event.getLevel().getBlockEntity(event.getPos()) instanceof net.dnorberg.rivencraft.block.entity.BookStandBlockEntity stand)) return; + var book = stand.getBook(); + if (!book.is(Rivencraft.LINKING_BOOK.get()) && !book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) return; + event.setCanceled(true); + event.setCancellationResult(InteractionResult.SUCCESS); + var source = new net.dnorberg.rivencraft.network.UseBookStandPayload( + event.getLevel().dimension().identifier(), event.getPos(), book.copy()); + Minecraft.getInstance().setScreenAndShow(book.is(Rivencraft.LINKING_BOOK.get()) + ? new PortLinkingBookScreen(source) : new PortDescriptiveBookScreen(source)); + } + } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java index 22d7069..6ecd435 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java @@ -10,81 +10,111 @@ import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.input.MouseButtonEvent; -import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; import net.minecraft.world.InteractionHand; import net.minecraft.world.item.ItemStack; import net.neoforged.neoforge.client.network.ClientPacketDistributor; -/** Temporary 26.3 descriptive-book interaction; authored pages and live previews remain unported. */ +/** Legacy book layout and authored glyph pages; destination rendering remains a port placeholder. */ public final class PortDescriptiveBookScreen extends Screen { private static final int WIDTH = 327; private static final int HEIGHT = 199; private static final int TEXT_COLOR = 0xFF3B281B; - private static final Identifier COVER = texture("bookui_cover.png"); - private static final Identifier LEFT_PAGE = texture("notebook_pages.png"); - private static final Identifier RIGHT_PANEL = texture("bookui_pager.png"); + private final InteractionHand hand; private final boolean bound; + private final PortLegacyLinkPanel linkPanel = new PortLegacyLinkPanel(); + private final net.dnorberg.rivencraft.writing.DescriptiveBookPages pages; + private int pageIndex; + private net.dnorberg.rivencraft.network.UseBookStandPayload standSource; private EditBox titleInput; public PortDescriptiveBookScreen(InteractionHand hand, ItemStack book) { super(book.getHoverName()); this.hand = hand; + pages = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(book); bound = book.has(Rivencraft.AGE_BOOK_ID.get()); } + public PortDescriptiveBookScreen(net.dnorberg.rivencraft.network.UseBookStandPayload source) { + this(InteractionHand.MAIN_HAND, source.book()); + standSource = source; + } + @Override protected void init() { - if (bound) return; + if (bound || standSource != null) return; int left = (width - WIDTH) / 2; int top = (height - HEIGHT) / 2; titleInput = new EditBox(font, left + 40, top + 69, 110, 20, Component.literal("Age name")); titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH); - titleInput.setHint(Component.literal("Name this Age")); + titleInput.setHint(Component.literal(standSource == null ? "Name this Age" : "Remove book to bind")); addRenderableWidget(titleInput); setInitialFocus(titleInput); addRenderableWidget(Button.builder(Component.literal("Bind"), button -> bind()) .bounds(left + 40, top + 100, 53, 20).build()); addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose()) .bounds(left + 97, top + 100, 53, 20).build()); + updatePageControls(); } @Override public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { int left = (width - WIDTH) / 2; int top = (height - HEIGHT) / 2; - blit(graphics, COVER, left, top + 7, 34, 192, 152, 0); - blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0); - blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0); - blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0); - blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0); - blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0); - graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false); - if (bound) { - graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D); - graphics.text(font, Component.literal("Age preview unavailable"), left + 181, top + 47, - 0xFFE9DFCB, false); - graphics.text(font, Component.literal("Click to enter"), left + 205, top + 65, - 0xFFE9DFCB, false); - } else { - graphics.text(font, Component.literal("Bind a prototype Age"), left + 183, top + 39, - TEXT_COLOR, false); + PortLegacyBookArtwork.drawCover(graphics, left, top, true); + if (pageIndex > 0) PortLegacyBookArtwork.drawLeftPage(graphics, left, top); + if (pageIndex == 0) { + if (bound) { + graphics.nextStratum(); + linkPanel.render(graphics, left, top, partialTick); + graphics.nextStratum(); + } + PortLegacyBookArtwork.drawPanelPage(graphics, left, top); + if (bound) { + graphics.enableScissor(left + 38, top + 38, left + 154, top + 174); + int textY = PortLegacyBookArtwork.drawText(graphics, font, title, left + 40, top + 40, 110, TEXT_COLOR); + Component author = pages.author().isBlank() + ? Component.translatable("screen.rivencraft.age.unknown_author") : Component.literal(pages.author()); + PortLegacyBookArtwork.drawText(graphics, font, author, left + 50, textY + 2, 100, TEXT_COLOR); + graphics.disableScissor(); + } else { + graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false); + graphics.text(font, Component.literal(standSource == null ? "Bind a prototype Age" : "Remove book to bind"), + left + 183, top + 39, TEXT_COLOR, false); + } + } else if (pages.symbolAt(pageIndex).isPresent()) { + PortLegacyBookArtwork.drawFullPage(graphics, left, top); + graphics.nextStratum(); + var symbol = pages.symbolAt(pageIndex).orElseThrow(); + ItemStack glyph = new ItemStack(Rivencraft.PAGE.get()); + glyph.set(Rivencraft.SYMBOL_PAGE_ID.get(), symbol); + glyph.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true); + graphics.enableScissor(left + 174, top + 26, left + 306, top + 172); + graphics.pose().pushMatrix(); + graphics.pose().translate(left + 177, top + 29); + graphics.pose().scale(8.0F, 8.0F); + graphics.fakeItem(glyph, 0, 0); + graphics.pose().popMatrix(); + graphics.disableScissor(); } + graphics.nextStratum(); + PortLegacyBookArtwork.drawCounter(graphics, font, left, top, pageIndex, pages.lastPage()); super.extractRenderState(graphics, mouseX, mouseY, partialTick); } @Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { - if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { + if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) { int left = (width - WIDTH) / 2; int top = (height - HEIGHT) / 2; - if (event.x() >= left + 173 && event.x() < left + 305 - && event.y() >= top + 20 && event.y() < top + 103) { - ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(hand == InteractionHand.OFF_HAND)); + int direction = PortLegacyBookArtwork.turnDirection(event.x() - left, event.y() - top, pageIndex, pages.lastPage()); + if (direction != 0) { turnPage(direction); return true; } + if (bound && pageIndex == 0 && PortLegacyLinkPanel.contains(event.x() - left, event.y() - top)) { + if (standSource != null) ClientPacketDistributor.sendToServer(standSource); + else ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(hand == InteractionHand.OFF_HAND)); onClose(); return true; } @@ -92,6 +122,36 @@ public final class PortDescriptiveBookScreen extends Screen { return super.mouseClicked(event, doubleClick); } + private void turnPage(int direction) { + int next = pages.turn(pageIndex, direction); + if (next == pageIndex) return; + pageIndex = next; + minecraft.getSoundManager().play(net.minecraft.client.resources.sounds.SimpleSoundInstance.forUI( + net.minecraft.sounds.SoundEvents.BOOK_PAGE_TURN, 1.0F, 0.65F)); + updatePageControls(); + } + + private void updatePageControls() { + for (var widget : renderables) if (widget instanceof net.minecraft.client.gui.components.AbstractWidget control) + control.visible = pageIndex == 0; + } + + @Override public boolean keyPressed(net.minecraft.client.input.KeyEvent event) { + if (event.isEscape()) return super.keyPressed(event); + if (titleInput == null || !titleInput.isFocused() || !titleInput.visible) { + if (event.isLeft()) { turnPage(-1); return true; } + if (event.isRight()) { turnPage(1); return true; } + } else { + // Keep inventory/hotbar actions out of a focused name field. + return titleInput.keyPressed(event); + } + return super.keyPressed(event); + } + + @Override public void tick() { + if (bound) linkPanel.tick(); + } + @Override public boolean isPauseScreen() { return false; @@ -105,12 +165,4 @@ public final class PortDescriptiveBookScreen extends Screen { onClose(); } - private static Identifier texture(String filename) { - return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename); - } - - private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y, - int width, int height, int u, int v) { - graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256); - } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyBookArtwork.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyBookArtwork.java new file mode 100644 index 0000000..7922df9 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyBookArtwork.java @@ -0,0 +1,70 @@ +package net.dnorberg.rivencraft.client; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.resources.Identifier; + +/** Original Mystcraft Legacy book atlases and page composition (LGPL-3.0, + * copyright XCompWiz, revision 9bc8ddc061845df0cd5ea47f4fdef773cff786ae). */ +final class PortLegacyBookArtwork { + private static final int ATLAS_SIZE = 256; + private static final Identifier COVER = texture("bookui_cover.png"); + private static final Identifier LEFT_PAGE = texture("notebook_pages.png"); + private static final Identifier RIGHT_PANEL = texture("bookui_pager.png"); + private static final Identifier RIGHT_FULL = texture("bookui_rpage_full.png"); + + private PortLegacyBookArtwork() {} + + static void drawCover(GuiGraphicsExtractor graphics, int left, int top, boolean gilded) { + blit(graphics, COVER, left, top + 7, 34, 192, 152, 0); + blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0); + blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0); + blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0); + if (gilded) { + blit(graphics, COVER, left, top + 7, 34, 192, 186, 0); + blit(graphics, COVER, left + 293, top + 7, 34, 192, 186, 0); + } + } + + static void drawLeftPage(GuiGraphicsExtractor graphics, int left, int top) { + blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0); + } + + static void drawPanelPage(GuiGraphicsExtractor graphics, int left, int top) { + blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0); + } + + static void drawFullPage(GuiGraphicsExtractor graphics, int left, int top) { + blit(graphics, RIGHT_FULL, left + 163, top, 156, 195, 0, 0); + } + + static int drawText(GuiGraphicsExtractor graphics, net.minecraft.client.gui.Font font, + net.minecraft.network.chat.Component text, int x, int y, int width, int color) { + for (var line : font.split(text, width)) { + graphics.text(font, line, x, y, color, false); + y += font.lineHeight; + } + return y; + } + + static void drawCounter(GuiGraphicsExtractor graphics, net.minecraft.client.gui.Font font, + int left, int top, int page, int last) { + String counter = page + "/" + last; + graphics.text(font, counter, left + 165 - font.width(counter) / 2, top + 185, 0xFF000000, false); + } + + static int turnDirection(double x, double y, int page, int last) { + if (y < 164 || y >= 195) return 0; + if (page > 0 && x >= 7 && x < 35) return -1; + return page < last && x >= 293 && x < 320 ? 1 : 0; + } + + private static Identifier texture(String filename) { + return Identifier.fromNamespaceAndPath("rivencraft", "textures/gui/" + filename); + } + + private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y, + int width, int height, int u, int v) { + graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, ATLAS_SIZE, ATLAS_SIZE); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyLinkPanel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyLinkPanel.java new file mode 100644 index 0000000..92e32dd --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyLinkPanel.java @@ -0,0 +1,76 @@ +package net.dnorberg.rivencraft.client; + +import net.minecraft.client.gui.GuiGraphicsExtractor; + +/** + * Built-in linking-panel animation inspired by Mystcraft Legacy's portal bloom. + * + *

The original effect used a GLSL noise shader over an optional live world + * view. This port retains the 1.21.1 fallback's two noisy spirals and default teal + * tint, using the normal GUI API without a renderer mod.

+ */ +final class PortLegacyLinkPanel { + static final int X = 173, Y = 20, WIDTH = 132, HEIGHT = 83; + private static final int CELL_SIZE = 3; + private static final int BORDER_COLOR = 0xFF7B6AA8; + private int ticks; + private static final int LINK_COLOR = 0xFF006666; + + static boolean contains(double x, double y) { + return x >= X && x < X + WIDTH && y >= Y && y < Y + HEIGHT; + } + + void render(GuiGraphicsExtractor graphics, int bookLeft, int bookTop, float partialTick) { + render(graphics, bookLeft + X, bookTop + Y, WIDTH, HEIGHT, partialTick); + } + + void tick() { + ticks++; + } + + private void render(GuiGraphicsExtractor graphics, int left, int top, int width, int height, float partialTick) { + graphics.fill(left, top, left + width, top + height, 0xFF000011); + + float time = (ticks + partialTick) * 0.035F; + float reveal = Math.min(1.0F, time / 1.8F); + for (int y = 1; y < height - 1; y += CELL_SIZE) { + for (int x = 1; x < width - 1; x += CELL_SIZE) { + float normalizedX = (x - width * 0.5F) / height; + float normalizedY = (y - height * 0.5F) / height; + float first = spiral(normalizedX, normalizedY, time, 0.0F); + float second = spiral(normalizedX, normalizedY, time, 2.35F); + float intensity = Math.max(first, second) * reveal; + if (intensity <= 0.035F) { + continue; + } + + int color = scaledColor(LINK_COLOR, Math.min(1.0F, 0.12F + intensity)); + graphics.fill(left + x, top + y, + left + Math.min(width - 1, x + CELL_SIZE), + top + Math.min(height - 1, y + CELL_SIZE), color); + } + } + graphics.outline(left, top, width, height, BORDER_COLOR); + } + + private static float spiral(float x, float y, float time, float phase) { + float distance = (float)Math.sqrt(x * x + y * y); + float angle = (float)Math.atan2(y, x); + float arm = 0.5F + 0.5F * (float)Math.cos(angle * 3.0F - distance * 25.0F + time * 4.0F + phase); + float pulse = 0.72F + 0.28F * noise(x * 31.0F, y * 31.0F, time + phase); + float core = Math.max(0.0F, 1.0F - distance * 2.15F); + return core * (0.28F + arm * 0.72F) * pulse; + } + + private static float noise(float x, float y, float time) { + double value = Math.sin(x * 12.9898D + y * 78.233D + time * 19.19D) * 43758.5453D; + return (float)(value - Math.floor(value)); + } + + private static int scaledColor(int color, float scale) { + int red = Math.min(255, Math.round(((color >> 16) & 0xFF) * scale + 18.0F * scale)); + int green = Math.min(255, Math.round(((color >> 8) & 0xFF) * scale + 24.0F * scale)); + int blue = Math.min(255, Math.round((color & 0xFF) * scale + 42.0F * scale)); + return 0xFF000000 | red << 16 | green << 8 | blue; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLinkingBookScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLinkingBookScreen.java index 11897b9..71a1b89 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLinkingBookScreen.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLinkingBookScreen.java @@ -10,35 +10,48 @@ import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.input.MouseButtonEvent; -import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; import net.minecraft.world.InteractionHand; import net.minecraft.world.item.ItemStack; import net.neoforged.neoforge.client.network.ClientPacketDistributor; -/** Minimal 26.3 book interaction using the Legacy artwork; live preview is not ported yet. */ +/** Legacy Linking Book front and final spreads with the shared fallback panel. */ public final class PortLinkingBookScreen extends Screen { private static final int WIDTH = 327; private static final int HEIGHT = 199; private static final int TEXT_COLOR = 0xFF3B281B; - private static final Identifier COVER = texture("bookui_cover.png"); - private static final Identifier LEFT_PAGE = texture("notebook_pages.png"); - private static final Identifier RIGHT_PANEL = texture("bookui_pager.png"); + private final InteractionHand hand; private final boolean bound; + private final net.dnorberg.rivencraft.item.LinkBookTarget target; + private final boolean returnLink; + private final Component destination; + private int pageIndex; + private final PortLegacyLinkPanel linkPanel = new PortLegacyLinkPanel(); + private net.dnorberg.rivencraft.network.UseBookStandPayload standSource; private EditBox titleInput; public PortLinkingBookScreen(InteractionHand hand, ItemStack book) { super(book.getHoverName()); this.hand = hand; - bound = book.has(Rivencraft.LINK_BOOK_TARGET.get()); + target = book.get(Rivencraft.LINK_BOOK_TARGET.get()); + bound = target != null; + destination = bound ? net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(book, target) : Component.empty(); + Component customName = book.get(net.minecraft.core.component.DataComponents.CUSTOM_NAME); + returnLink = customName != null + && customName.getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents translated + && translated.getKey().equals("item.rivencraft.linking_book.return"); + } + + public PortLinkingBookScreen(net.dnorberg.rivencraft.network.UseBookStandPayload source) { + this(InteractionHand.MAIN_HAND, source.book()); + standSource = source; } @Override protected void init() { - if (bound) return; + if (bound || standSource != null) return; int left = (width - WIDTH) / 2; int top = (height - HEIGHT) / 2; titleInput = new EditBox(font, left + 40, top + 69, 110, 20, @@ -51,38 +64,55 @@ public final class PortLinkingBookScreen extends Screen { .bounds(left + 40, top + 100, 53, 20).build()); addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose()) .bounds(left + 97, top + 100, 53, 20).build()); + updatePageControls(); } @Override public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { int left = (width - WIDTH) / 2; int top = (height - HEIGHT) / 2; - blit(graphics, COVER, left, top + 7, 34, 192, 152, 0); - blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0); - blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0); - blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0); - if (bound) blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0); - blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0); - graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false); - if (bound) { - graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D); - graphics.text(font, Component.literal("Destination preview"), left + 185, top + 47, 0xFFE9DFCB, false); - graphics.text(font, Component.literal("Click to link"), left + 205, top + 65, 0xFFE9DFCB, false); + PortLegacyBookArtwork.drawCover(graphics, left, top, false); + if (pageIndex > 0) { + PortLegacyBookArtwork.drawLeftPage(graphics, left, top); } else { - graphics.text(font, Component.literal("Name this Linking Book"), left + 183, top + 39, - TEXT_COLOR, false); + if (bound) { + graphics.nextStratum(); + linkPanel.render(graphics, left, top, partialTick); + graphics.nextStratum(); + } + PortLegacyBookArtwork.drawPanelPage(graphics, left, top); + graphics.enableScissor(left + 38, top + 38, left + 154, top + 174); + if (bound) { + int y = top + 40; + if (!returnLink) y = PortLegacyBookArtwork.drawText(graphics, font, title, + left + 40, y, 110, TEXT_COLOR) + 4; + y = PortLegacyBookArtwork.drawText(graphics, font, destination, left + 50, y, 100, TEXT_COLOR) + 10; + var pos = net.minecraft.core.BlockPos.containing(target.x(), target.y(), target.z()); + PortLegacyBookArtwork.drawText(graphics, font, + Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()), + left + 40, y, 110, TEXT_COLOR); + } else { + PortLegacyBookArtwork.drawText(graphics, font, Component.literal( + standSource == null ? "Name this Linking Book" : "Remove book to bind"), + left + 40, top + 43, 110, TEXT_COLOR); + } + graphics.disableScissor(); } + graphics.nextStratum(); + PortLegacyBookArtwork.drawCounter(graphics, font, left, top, pageIndex, 1); super.extractRenderState(graphics, mouseX, mouseY, partialTick); } @Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { - if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { + if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) { int left = (width - WIDTH) / 2; int top = (height - HEIGHT) / 2; - if (event.x() >= left + 173 && event.x() < left + 305 - && event.y() >= top + 20 && event.y() < top + 103) { - ClientPacketDistributor.sendToServer(new UseLinkingBookPayload(hand == InteractionHand.OFF_HAND)); + int direction = PortLegacyBookArtwork.turnDirection(event.x() - left, event.y() - top, pageIndex, 1); + if (direction != 0) { turnPage(direction); return true; } + if (bound && pageIndex == 0 && PortLegacyLinkPanel.contains(event.x() - left, event.y() - top)) { + if (standSource != null) ClientPacketDistributor.sendToServer(standSource); + else ClientPacketDistributor.sendToServer(new UseLinkingBookPayload(hand == InteractionHand.OFF_HAND)); onClose(); return true; } @@ -90,6 +120,33 @@ public final class PortLinkingBookScreen extends Screen { return super.mouseClicked(event, doubleClick); } + private void turnPage(int direction) { + int next = Math.clamp(pageIndex + direction, 0, 1); + if (next == pageIndex) return; + pageIndex = next; + minecraft.getSoundManager().play(net.minecraft.client.resources.sounds.SimpleSoundInstance.forUI( + net.minecraft.sounds.SoundEvents.BOOK_PAGE_TURN, 1.0F, 0.65F)); + updatePageControls(); + } + + private void updatePageControls() { + for (var widget : renderables) if (widget instanceof net.minecraft.client.gui.components.AbstractWidget control) + control.visible = pageIndex == 0; + } + + @Override public boolean keyPressed(net.minecraft.client.input.KeyEvent event) { + if (event.isEscape()) return super.keyPressed(event); + if (titleInput == null || !titleInput.isFocused() || !titleInput.visible) { + if (event.isLeft()) { turnPage(-1); return true; } + if (event.isRight()) { turnPage(1); return true; } + } else return titleInput.keyPressed(event); + return super.keyPressed(event); + } + + @Override public void tick() { + if (bound) linkPanel.tick(); + } + @Override public boolean isPauseScreen() { return false; @@ -103,12 +160,4 @@ public final class PortLinkingBookScreen extends Screen { onClose(); } - private static Identifier texture(String filename) { - return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename); - } - - private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y, - int width, int height, int u, int v) { - graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256); - } } 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 index 84df26b..f7a4b8c 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/entity/DroppedBookEntity.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/entity/DroppedBookEntity.java @@ -26,6 +26,17 @@ public final class DroppedBookEntity extends ItemEntity { setPickUpDelay(20); } + /** Conversion preserves ordinary toss/drop state; the travel constructor has different placement rules. */ + public static DroppedBookEntity fromDrop(ItemEntity source) { + var book = new DroppedBookEntity(Rivencraft.DROPPED_BOOK.get(), source.level()); + book.restoreFrom(source); + // The original remains registered until replacement succeeds, so this needs a new identity. + book.setUUID(java.util.UUID.randomUUID()); + book.setItem(source.getItem().copy()); + book.setUnlimitedLifetime(); + return book; + } + @Override public void tick() { super.tick(); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java new file mode 100644 index 0000000..293bbc8 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java @@ -0,0 +1,40 @@ +package net.dnorberg.rivencraft.event; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.BookStandBlock; +import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity; +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LecternBlock; +import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; + +public final class BookStandConversionHandler { + private BookStandConversionHandler() {} + public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) { + var book = event.getItemStack(); + if (!book.is(Rivencraft.LINKING_BOOK.get()) && !book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) && !book.is(Rivencraft.PAGE.get())) return; + var state = event.getLevel().getBlockState(event.getPos()); + if (!state.is(Blocks.LECTERN) || state.getValue(LecternBlock.HAS_BOOK)) return; + event.setCanceled(true); + event.setCancellationResult(InteractionResult.SUCCESS); + if (!event.getLevel().isClientSide()) convert(event.getEntity(), event.getHand(), event.getPos()); + } + public static boolean convert(Player player, InteractionHand hand, BlockPos pos) { + var level = player.level(); + var state = level.getBlockState(pos); + var book = player.getItemInHand(hand); + if (level.isClientSide() || !state.is(Blocks.LECTERN) || state.getValue(LecternBlock.HAS_BOOK) + || (!book.is(Rivencraft.LINKING_BOOK.get()) && !book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) && !book.is(Rivencraft.PAGE.get()))) return false; + if (!level.setBlock(pos, Rivencraft.BOOK_STAND.get().defaultBlockState() + .setValue(BookStandBlock.FACING, state.getValue(LecternBlock.FACING)).setValue(BookStandBlock.HAS_BOOK, true), 3)) return false; + if (!(level.getBlockEntity(pos) instanceof BookStandBlockEntity stand)) return false; + stand.setBook(book.consumeAndReturn(1, player)); + level.playSound(null, pos, SoundEvents.BOOK_PUT, SoundSource.BLOCKS, 1, 1); + return true; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/event/DroppedBookConversionHandler.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/event/DroppedBookConversionHandler.java new file mode 100644 index 0000000..d6ae3e2 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/event/DroppedBookConversionHandler.java @@ -0,0 +1,37 @@ +package net.dnorberg.rivencraft.event; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.entity.DroppedBookEntity; +import net.minecraft.server.TickTask; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.neoforged.bus.api.EventPriority; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent; + +/** Converts ordinary book drops from every source, including entities restored from disk. */ +@EventBusSubscriber(modid = Rivencraft.MOD_ID) +public final class DroppedBookConversionHandler { + private DroppedBookConversionHandler() {} + + @SubscribeEvent(priority = EventPriority.LOWEST) + public static void onJoin(EntityJoinLevelEvent event) { + if (!(event.getLevel() instanceof ServerLevel level) + || !(event.getEntity() instanceof ItemEntity item) || item instanceof DroppedBookEntity + || !isBook(item.getItem())) return; + // Join events can run before chunk promotion. Do not add another entity inside that callback. + // Keep the original until the queued replacement has actually been accepted. + level.getServer().schedule(new TickTask(0, () -> { + if (level.getServer().getLevel(level.dimension()) != level || item.isRemoved() + || !item.isAddedToLevel() || item.level() != level || !isBook(item.getItem())) return; + var replacement = DroppedBookEntity.fromDrop(item); + if (level.addFreshEntity(replacement)) item.discard(); + })); + } + + private static boolean isBook(ItemStack stack) { + return stack.is(Rivencraft.LINKING_BOOK.get()) || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java index c8d6624..9e846f3 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java @@ -102,13 +102,37 @@ public final class LinkingBookItem extends Item { } } + public static String destinationName(ServerLevel level) { + var id = level.dimension().identifier(); + return net.dnorberg.rivencraft.age.RuntimeAgeBackend.savedAgeName(level.getServer(), id) + .orElseGet(() -> { + if (id.equals(Level.OVERWORLD.identifier())) return "Overworld"; + if (id.equals(Level.NETHER.identifier())) return "The Nether"; + if (id.equals(Level.END.identifier())) return "The End"; + return id.toString(); + }); + } + + public static Component destinationName(ItemStack book, LinkBookTarget target) { + String name = book.get(Rivencraft.LINK_DESTINATION_NAME.get()); + if (name != null && !name.isBlank()) return Component.literal(name); + var id = target.dimension(); + if (id.equals(Level.OVERWORLD.identifier())) return Component.translatable("dimension.minecraft.overworld"); + if (id.equals(Level.NETHER.identifier())) return Component.translatable("dimension.minecraft.the_nether"); + if (id.equals(Level.END.identifier())) return Component.translatable("dimension.minecraft.the_end"); + return Component.literal(id.toString()); + } + @Override public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, Consumer tooltip, TooltipFlag flag) { LinkBookTarget target = stack.get(Rivencraft.LINK_BOOK_TARGET.get()); - tooltip.accept(Component.literal(target == null ? "Unbound (26.3 port preview)" - : "Destination: " + target.dimension() + " (" + (int) target.x() + ", " - + (int) target.y() + ", " + (int) target.z() + ")")); + if (target == null) tooltip.accept(Component.literal("Unbound (26.3 port preview)")); + else { + BlockPos pos = BlockPos.containing(target.x(), target.y(), target.z()); + tooltip.accept(Component.translatable("item.rivencraft.linking_book.destination", + destinationName(stack, target).getString(), pos.getX(), pos.getY(), pos.getZ())); + } LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE); if (properties.has(LinkProperties.DISARM)) tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm")); 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 index 6e9f2ad..c2214dc 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PortHandheldTravel.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PortHandheldTravel.java @@ -21,6 +21,7 @@ public final class PortHandheldTravel { try { String failure = travel.get(); if (failure != null) return failure; + net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player); dropped = source.addFreshEntity(new DroppedBookEntity(source, departure, book)); return null; } finally { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/InkMixerMenu.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/InkMixerMenu.java index bbe3cb1..202a8c6 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/InkMixerMenu.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/InkMixerMenu.java @@ -1,6 +1,8 @@ package net.dnorberg.rivencraft.menu; import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.advancement.ProgressionAdvancements; +import net.minecraft.server.level.ServerPlayer; import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.world.Container; @@ -110,6 +112,8 @@ public final class InkMixerMenu extends AbstractContainerMenu { if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) return ItemStack.EMPTY; if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY; + if (player instanceof ServerPlayer serverPlayer) + ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL); refreshResult(); return output; } @@ -150,6 +154,8 @@ public final class InkMixerMenu extends AbstractContainerMenu { } @Override public void onTake(Player player, ItemStack stack) { if (!clientSide && mixer.consumeLinkPanelMaterials()) { + if (player instanceof ServerPlayer serverPlayer) + ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL); refreshResult(); } super.onTake(player, stack); 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 05751f1..845f0e0 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 @@ -118,6 +118,7 @@ public final class PortBookBinderMenu extends AbstractContainerMenu { var id = PortBookNetworking.createAge(player, title, blockEntity.symbolPages()); if (id == null) return; book.set(Rivencraft.AGE_BOOK_ID.get(), id); + book.set(Rivencraft.BOOK_AUTHOR.get(), player.getGameProfile().name()); book.set(DataComponents.CUSTOM_NAME, Component.literal(title)); if (blockEntity.hasSymbolPages()) book.set(Rivencraft.BOOK_SYMBOLS.get(), blockEntity.symbolPages()); @@ -129,6 +130,8 @@ public final class PortBookBinderMenu extends AbstractContainerMenu { binder.setItem(PortBookBinderBlockEntity.PANEL, panel); blockEntity.clearSymbolPages(); setCarried(book); + net.dnorberg.rivencraft.advancement.ProgressionAdvancements.award(player, + net.dnorberg.rivencraft.advancement.ProgressionAdvancements.BIND_AGE); broadcastChanges(); } 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 index 8744631..c7f23ff 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/WritingDeskMenu.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/WritingDeskMenu.java @@ -1,6 +1,8 @@ package net.dnorberg.rivencraft.menu; import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.advancement.ProgressionAdvancements; +import net.minecraft.server.level.ServerPlayer; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.writing.NotebookContents; @@ -321,6 +323,8 @@ public final class WritingDeskMenu extends AbstractContainerMenu { return ItemStack.EMPTY; } if (!blockEntity.consumeCopyMaterials()) return ItemStack.EMPTY; + if (player instanceof ServerPlayer serverPlayer) + ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.COPY_PAGE); refreshOutput(); return copy; } @@ -439,6 +443,8 @@ public final class WritingDeskMenu extends AbstractContainerMenu { } @Override public void onTake(Player player, ItemStack stack) { if (!clientSide && blockEntity.consumeCopyMaterials()) { + if (player instanceof ServerPlayer serverPlayer) + ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.COPY_PAGE); refreshOutput(); } super.onTake(player, stack); 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 4f239a6..24d0c40 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 @@ -38,6 +38,10 @@ public final class PortBookNetworking { (payload, context) -> { if (context.player() instanceof ServerPlayer player) bind(player, payload); }); + registrar.playToServer(UseBookStandPayload.TYPE, UseBookStandPayload.STREAM_CODEC, + (payload, context) -> { + if (context.player() instanceof ServerPlayer player) useStand(player, payload); + }); registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC, (payload, context) -> { if (context.player() instanceof ServerPlayer player) use(player, payload); @@ -73,6 +77,7 @@ public final class PortBookNetworking { String title = payload.title().strip(); if (title.isEmpty() || title.length() > MAX_BOOK_TITLE_LENGTH) return; stack.set(Rivencraft.LINK_BOOK_TARGET.get(), LinkBookTarget.at(player)); + stack.set(Rivencraft.LINK_DESTINATION_NAME.get(), LinkingBookItem.destinationName(player.level())); stack.set(DataComponents.CUSTOM_NAME, Component.literal(title)); player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F); @@ -96,6 +101,7 @@ public final class PortBookNetworking { Identifier id = createAge(player, title); if (id == null) return; stack.set(Rivencraft.AGE_BOOK_ID.get(), id); + stack.set(Rivencraft.BOOK_AUTHOR.get(), player.getGameProfile().name()); stack.set(DataComponents.CUSTOM_NAME, Component.literal(title)); player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F); @@ -157,6 +163,7 @@ public final class PortBookNetworking { net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld()))); net.dnorberg.rivencraft.age.PortAcceleratedTicks.configure(level, net.dnorberg.rivencraft.age.PortAcceleratedTicks.pageCount(pages)); + RuntimeAgeBackend.setAgeDisplayName(server, id, title); } catch (IllegalArgumentException | IllegalStateException exception) { player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage())); return null; @@ -167,22 +174,51 @@ public final class PortBookNetworking { private static void enterAge(ServerPlayer player, UseDescriptiveBookPayload payload) { ItemStack stack = player.getItemInHand(hand(payload.offhand())); if (!stack.is(Rivencraft.DESCRIPTIVE_BOOK.get())) return; + travelDescriptive(player, stack, hand(payload.offhand())); + } + + /** Validation is shared with operator fixtures; no remote chunk is loaded to inspect a stand. */ + public static ItemStack validatedStandBook(ServerPlayer player, UseBookStandPayload payload) { + var level = player.level(); + if (!level.dimension().identifier().equals(payload.dimension()) + || player.distanceToSqr(Vec3.atCenterOf(payload.pos())) > 64 + || !level.hasChunkAt(payload.pos()) + || !level.getBlockState(payload.pos()).is(Rivencraft.BOOK_STAND.get()) + || !(level.getBlockEntity(payload.pos()) instanceof net.dnorberg.rivencraft.block.entity.BookStandBlockEntity stand) + || !ItemStack.matches(stand.getBook(), payload.book())) return ItemStack.EMPTY; + return stand.getBook(); + } + + private static void useStand(ServerPlayer player, UseBookStandPayload payload) { + var book = validatedStandBook(player, payload); + if (book.is(Rivencraft.LINKING_BOOK.get())) { + String failure = LinkingBookItem.travel(player, book); + if (failure != null) player.sendOverlayMessage(Component.literal(failure)); + else net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player); + } else if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) travelDescriptive(player, book, null); + } + + private static void travelDescriptive(ServerPlayer player, ItemStack stack, InteractionHand heldHand) { 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 = 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; - }); + java.util.function.Supplier travel = () -> { + 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; + }; + String failure = heldHand == null ? travel.get() + : net.dnorberg.rivencraft.item.PortHandheldTravel.travel(player, heldHand, stack, travel); if (failure != null) player.sendOverlayMessage(Component.literal(failure)); - else net.dnorberg.rivencraft.age.PortReturnLinks.afterSuccessfulTravel(player, id, returnTarget); + else { + if (heldHand == null) net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player); + 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/UseBookStandPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseBookStandPayload.java new file mode 100644 index 0000000..1fa0c78 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseBookStandPayload.java @@ -0,0 +1,18 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.ItemStack; + +/** Carries the displayed source so a removed/replaced book cannot activate a stale screen. */ +public record UseBookStandPayload(Identifier dimension, BlockPos pos, ItemStack book) implements CustomPacketPayload { + public static final Type TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_book_stand")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + Identifier.STREAM_CODEC, UseBookStandPayload::dimension, BlockPos.STREAM_CODEC, UseBookStandPayload::pos, + ItemStack.STREAM_CODEC, UseBookStandPayload::book, UseBookStandPayload::new); + @Override public Type type() { return TYPE; } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/DescriptiveBookPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/DescriptiveBookPages.java new file mode 100644 index 0000000..da0fc72 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/DescriptiveBookPages.java @@ -0,0 +1,21 @@ +package net.dnorberg.rivencraft.writing; + +import java.util.List; +import java.util.Optional; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.ItemStack; + +/** Client-readable snapshot of authored pages; index zero is the front/link-panel spread. */ +public record DescriptiveBookPages(List symbols, String author) { + public DescriptiveBookPages { symbols = List.copyOf(symbols); } + public static DescriptiveBookPages from(ItemStack book) { + return new DescriptiveBookPages(book.getOrDefault(Rivencraft.BOOK_SYMBOLS.get(), List.of()), + book.getOrDefault(Rivencraft.BOOK_AUTHOR.get(), "")); + } + public int lastPage() { return Math.max(1, symbols.size()); } + public int turn(int page, int direction) { return Math.clamp(page + direction, 0, lastPage()); } + public Optional symbolAt(int page) { + return page >= 1 && page <= symbols.size() ? Optional.of(symbols.get(page - 1)) : Optional.empty(); + } +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/advancement/progression/root.json b/ports/mc263/src/main/resources/data/rivencraft/advancement/progression/root.json new file mode 100644 index 0000000..c179302 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/advancement/progression/root.json @@ -0,0 +1,21 @@ +{ + "display": { + "icon": { + "id": "rivencraft:descriptive_book" + }, + "title": { + "translate": "advancement.rivencraft.root.title" + }, + "description": { + "translate": "advancement.rivencraft.root.description" + }, + "background": "rivencraft:block/crystal_block", + "show_toast": false, + "announce_to_chat": false + }, + "criteria": { + "completed": { + "trigger": "minecraft:tick" + } + } +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/book_binder.json b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/book_binder.json new file mode 100644 index 0000000..8b93469 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/book_binder.json @@ -0,0 +1,34 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_material": { + "trigger": "minecraft:inventory_changed", + "conditions": { + "items": [ + { + "items": "minecraft:iron_ingot" + } + ] + } + }, + "has_the_recipe": { + "trigger": "minecraft:recipe_unlocked", + "conditions": { + "recipes": [ + "rivencraft:book_binder" + ] + } + } + }, + "requirements": [ + [ + "has_material", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "rivencraft:book_binder" + ] + } +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/crystal_block.json b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/crystal_block.json new file mode 100644 index 0000000..b267f92 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/crystal_block.json @@ -0,0 +1,34 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_crystal_shard": { + "trigger": "minecraft:inventory_changed", + "conditions": { + "items": [ + { + "items": "rivencraft:crystal_shard" + } + ] + } + }, + "has_the_recipe": { + "trigger": "minecraft:recipe_unlocked", + "conditions": { + "recipes": [ + "rivencraft:crystal_block" + ] + } + } + }, + "requirements": [ + [ + "has_crystal_shard", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "rivencraft:crystal_block" + ] + } +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_mixer.json b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_mixer.json new file mode 100644 index 0000000..d5714d9 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_mixer.json @@ -0,0 +1,34 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_material": { + "trigger": "minecraft:inventory_changed", + "conditions": { + "items": [ + { + "items": "minecraft:glass_bottle" + } + ] + } + }, + "has_the_recipe": { + "trigger": "minecraft:recipe_unlocked", + "conditions": { + "recipes": [ + "rivencraft:ink_mixer" + ] + } + } + }, + "requirements": [ + [ + "has_material", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "rivencraft:ink_mixer" + ] + } +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_vial.json b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_vial.json new file mode 100644 index 0000000..001df19 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_vial.json @@ -0,0 +1,34 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_black_dye": { + "trigger": "minecraft:inventory_changed", + "conditions": { + "items": [ + { + "items": "minecraft:black_dye" + } + ] + } + }, + "has_the_recipe": { + "trigger": "minecraft:recipe_unlocked", + "conditions": { + "recipes": [ + "rivencraft:ink_vial" + ] + } + } + }, + "requirements": [ + [ + "has_black_dye", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "rivencraft:ink_vial" + ] + } +}