Compare commits

...
4 Commits
Author SHA1 Message Date
dnorberg 8246640f49 Complete Milestone 4 writing acceptance and migration coverage (#26, #27)
Build / build (push) Canceled after 0s
2026-09-29 22:51:49 -04:00
dnorberg ba2faace15 Use Legacy Linking Book recipe and add page-turn audio
Build / build (push) Canceled after 0s
2026-09-29 22:37:45 -04:00
dnorberg 88355ee276 Add Ink Mixer and optional JEI writing guides
Build / build (push) Canceled after 0s
2026-09-29 10:35:29 -04:00
dnorberg 568490e625 Persist ordered book writing and restore Legacy book pages
Build / build (push) Canceled after 0s
2026-09-29 09:46:17 -04:00
59 changed files with 1559 additions and 156 deletions

No files matched your search

+3
View File
@@ -16,6 +16,9 @@ The Writing Desk block texture is copied from Legacy's
assets/mystcraft/textures/entity/desk.png under the same license. assets/mystcraft/textures/entity/desk.png under the same license.
The Writing Desk GUI atlas is copied from Legacy's assets/mystcraft/gui/writingdesk.png The Writing Desk GUI atlas is copied from Legacy's assets/mystcraft/gui/writingdesk.png
under the same license; desk wood and inlay textures are cropped from desk.png. under the same license; desk wood and inlay textures are cropped from desk.png.
The Ink Mixer GUI atlas and block textures are copied from Legacy's
assets/mystcraft/gui/inkmixer.png and textures/blocks/inkmixer_*.png under
the same license. Rivencraft's block model is original and uses those textures.
The initial build layout is derived from the official NeoForge 1.21.1 The initial build layout is derived from the official NeoForge 1.21.1
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
+35 -5
View File
@@ -4,8 +4,8 @@ Rivencraft is a clean, incremental port of the ideas and gameplay of
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
1.21.1 on NeoForge. 1.21.1 on NeoForge.
The bootstrap, books/linking, and Ages milestones are complete. Milestone 4 is The bootstrap, books/linking, Ages, and writing-system milestones are complete.
building the writing system on a data-pack symbol catalog and persistent The writing system uses a data-pack symbol catalog and persistent
ordered-writing model. The project currently provides functional Linking Books ordered-writing model. The project currently provides functional Linking Books
and randomly seeded, persistent vanilla-like Ages created from page-free and randomly seeded, persistent vanilla-like Ages created from page-free
Descriptive Books. Instability and full symbol-to-world grammar interpretation Descriptive Books. Instability and full symbol-to-world grammar interpretation
@@ -19,6 +19,11 @@ hand and use the Ink Vial in the other to prepare a baseline Link Panel Page.
Each vial holds 16 ink units, and the default link-panel cost is two units. Each vial holds 16 ink units, and the default link-panel cost is two units.
The spent vial returns a glass bottle. Server owners can adjust the symbol-copy The spent vial returns a glass bottle. Server owners can adjust the symbol-copy
and link-panel costs in the `writing` section of `rivencraft-server.toml`. and link-panel costs in the `writing` section of `rivencraft-server.toml`.
The Ink Mixer now offers a Legacy-style station for producing Link Panels:
insert paper and Ink Vials, then take a Link Panel from the result slot. Each
vial fills its 16-unit basin and returns a glass bottle; no server tick is
needed. The earlier paper-in-one-hand method remains available. Ink modifier
ingredients and link properties are planned for Milestone 5.
The Writing Desk can copy a selected written page from its collection tabs. The Writing Desk can copy a selected written page from its collection tabs.
The Writing Desk is now craftable from planks, a glass bottle, and a feather. The Writing Desk is now craftable from planks, a glass bottle, and a feather.
It occupies two adjacent blocks, with one shared inventory; loaded paper and It occupies two adjacent blocks, with one shared inventory; loaded paper and
@@ -61,6 +66,21 @@ portrait preview and name above the player inventory.
## Book Binder ## Book Binder
For a new survival Age: craft an Ink Vial, an Ink Mixer, a Writing Desk, and a Book Binder.
Put vanilla paper and an Ink Vial in the Ink Mixer to make a Link Panel
Page. Place leather (or an empty Notebook) and the Link Panel in the Book Binder,
enter an Age name, and take the finished Descriptive Book. Written symbol Pages
are optional; without them, the book creates a random Age. To duplicate a Page
you already own, store it in a Writing Desk tab, select it, supply paper and ink,
and take the copied Page from the output. A Notebook can hold and transfer a
larger ordered collection of Pages between desks and the binder.
With JEI installed, Rivencraft displays the Ink Mixer operation in its own
category and the hand-use, copying, binding, and Linking Book crafting flows in a Writing category.
JEI is optional; vanilla crafting
recipes and all writing interactions work without it. For a development client
with JEI, run `./gradlew runClient -PwithJei=true`.
Craft a Book Binder from three iron ingots and five planks. Put leather or an Craft a Book Binder from three iron ingots and five planks. Put leather or an
empty Notebook in the left cover slot. Enter a title, then place a Link Panel empty Notebook in the left cover slot. Enter a title, then place a Link Panel
Page first in the horizontal page strip. Click a page in the strip with a Page first in the horizontal page strip. Click a page in the strip with a
@@ -73,17 +93,27 @@ Inputs are consumed only when the result is taken.
The completed book receives its Age identity when assembled. With only a Link The completed book receives its Age identity when assembled. With only a Link
Panel, it describes a randomly generated Age. Its writing is stored on the Panel, it describes a randomly generated Age. Its writing is stored on the
book; Milestone 5 will interpret additional symbols during Age generation. book; the Descriptive Book screen's turned pages show ordered symbols and
unresolved IDs. Until Milestone 5, symbols do not affect generation and written
books use the same random Age fallback. The Descriptive Book screen follows
Legacy's book spread: the title and Age details are on the left, the destination
preview is on the right, and clicks in the bottom corners turn pages. Ordered
symbol pages appear before the final blank spread; their pictograms await
Milestone 5. Link-panel-only books show their details in the enchantment-table
font.
## Linking Book ## Linking Book
Craft a Linking Book from a vanilla book and an ender pearl, then use it in the Craft an unwritten Linking Book from one Link Panel Page and one leather in any
air. An unbound book opens a screen where it can be named and bound to the two crafting-grid positions. An unbound book opens a screen where it can be named and bound to the
current location. A bound book opens its destination screen and travels when current location. A bound book opens its destination screen and travels when
its preview panel is clicked. The destination is stored on the item and its preview panel is clicked. The destination is stored on the item and
survives inventory and world saves. Travel only succeeds if the target survives inventory and world saves. Travel only succeeds if the target
dimension is loaded and a non-fluid, passable landing position can be found dimension is loaded and a non-fluid, passable landing position can be found
nearby. nearby.
The Linking Book uses the same Legacy page layout and bottom-corner page turns
as the Descriptive Book, but without the Descriptive Book's gold cover edges.
Both screens play Minecraft's book-page-turn sound when a page is turned.
Successful book travel plays Mystcraft Legacy's original linking sound at both Successful book travel plays Mystcraft Legacy's original linking sound at both
the departure and arrival locations. the departure and arrival locations.
+14 -6
View File
@@ -27,6 +27,14 @@ sourceSets.main.resources {
} }
repositories { repositories {
maven {
name = 'JaredsMaven'
url = 'https://maven.blamejared.com'
content {
includeGroup 'mezz.jei'
includeGroup 'net.mezzdev.config'
}
}
maven { url = 'https://maven.createmod.net' } maven { url = 'https://maven.createmod.net' }
maven { maven {
url = 'https://www.cursemaven.com' url = 'https://www.cursemaven.com'
@@ -172,12 +180,12 @@ dependencies {
compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}" compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}"
implementation "curse.maven:dynamicdimension-ar-edition-1469680:${dynamic_dimensions_file_id}" implementation "curse.maven:dynamicdimension-ar-edition-1469680:${dynamic_dimensions_file_id}"
implementation "lol.bai:badpackets:neo-${badpackets_version}" implementation "lol.bai:badpackets:neo-${badpackets_version}"
// Example optional mod dependency with JEI // JEI integration is isolated in its plugin and absent from the published runtime dependencies.
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime compileOnly "mezz.jei:jei-${minecraft_version}-neoforge-api:${jei_version}"
// compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}" // Opt in to JEI for a dev client with -PwithJei=true.
// compileOnly "mezz.jei:jei-${mc_version}-neoforge-api:${jei_version}" if (providers.gradleProperty('withJei').map { it.toBoolean() }.orElse(false).get()) {
// We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it localRuntime "mezz.jei:jei-${minecraft_version}-neoforge:${jei_version}"
// localRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}" }
// Example mod dependency using a mod jar from ./libs with a flat dir repository // Example mod dependency using a mod jar from ./libs with a flat dir repository
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar // This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+6
View File
@@ -8,6 +8,10 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only | | `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/gui/notebook_pages.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Notebook draws the native 30×40 page at atlas coordinates `(156, 0)` | | `assets/rivencraft/textures/gui/notebook_pages.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Notebook draws the native 30×40 page at atlas coordinates `(156, 0)` |
| `assets/rivencraft/textures/gui/bookui_cover.png` | `assets/mystcraft/gui/bookui_cover.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; shared book cover, with gold edges drawn only for Descriptive Books |
| `assets/rivencraft/textures/gui/bookui_pagel.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; turned left page |
| `assets/rivencraft/textures/gui/bookui_pager.png` | `assets/mystcraft/gui/bookui_pager.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; link-panel frame on the first right page |
| `assets/rivencraft/textures/gui/bookui_rpage_full.png` | `assets/mystcraft/gui/bookui_rpage_full.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; full right written page |
| `assets/rivencraft/textures/item/writing_desk.png` | `assets/mystcraft/textures/items/writingdesk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only | | `assets/rivencraft/textures/item/writing_desk.png` | `assets/mystcraft/textures/items/writingdesk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/block/writing_desk.png` | `assets/mystcraft/textures/entity/desk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only | | `assets/rivencraft/textures/block/writing_desk.png` | `assets/mystcraft/textures/entity/desk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/gui/writing_desk_legacy.png` | `assets/mystcraft/gui/writingdesk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; full desk panel, tabs, and cycling controls at original atlas coordinates | | `assets/rivencraft/textures/gui/writing_desk_legacy.png` | `assets/mystcraft/gui/writingdesk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; full desk panel, tabs, and cycling controls at original atlas coordinates |
@@ -15,6 +19,8 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| `assets/rivencraft/textures/block/book_binder.png` | `assets/mystcraft/textures/entity/bookbinder/bookbinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this body texture | | `assets/rivencraft/textures/block/book_binder.png` | `assets/mystcraft/textures/entity/bookbinder/bookbinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this body texture |
| `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture | | `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture |
| `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel | | `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel |
| `assets/rivencraft/textures/gui/ink_mixer_legacy.png` | `assets/mystcraft/gui/inkmixer.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Ink Mixer GUI uses the original 176×181 panel |
| `assets/rivencraft/textures/block/ink_mixer_top.png`, `ink_mixer_side.png`, `ink_mixer_bottom.png` | `assets/mystcraft/textures/blocks/inkmixer_top.png`, `inkmixer_side.png`, `inkmixer_bottom.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Rivencraft supplies its own block model |
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only | | `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample | | `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
| `assets/rivencraft/textures/item/page_symbol*.png` and `page_unresolved.png` | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Static 1.21.1 renditions of the legacy runtime-composed four-word symbol pages; `normal_time` uses the legacy Full Length poem | | `assets/rivencraft/textures/item/page_symbol*.png` and `page_unresolved.png` | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Static 1.21.1 renditions of the legacy runtime-composed four-word symbol pages; `normal_time` uses the legacy Full Length poem |
+8 -3
View File
@@ -33,8 +33,10 @@ acceptance issue and all required implementation issues are complete.
lifecycle management, data-pack integration, and optional destination lifecycle management, data-pack integration, and optional destination
renderer compatibility. **Complete.** See renderer compatibility. **Complete.** See
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8). [Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
4. **Writing system** — pages, symbols, notebooks, desks, and book assembly. 4. **Writing system** — pages, notebooks, desks, book assembly, and the baseline
**In progress.** Ink Mixer for Link Panels. Symbol effects are deferred until Milestone 5.
**Complete.** See [Milestone 4](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/9)
and [writing-system data and migration](WRITING_DATA.md).
5. **Age grammar** — translate symbol combinations into modern biome, noise, 5. **Age grammar** — translate symbol combinations into modern biome, noise,
dimension-type, and world-generation configuration. Optional integrations dimension-type, and world-generation configuration. Optional integrations
may add composable biome regions and seasonal behavior. may add composable biome regions and seasonal behavior.
@@ -62,10 +64,13 @@ The writing system and Age grammar will build on the existing page-free Age
path. Page-free books remain a useful deterministic fallback and test fixture path. Page-free books remain a useful deterministic fallback and test fixture
after written symbol combinations become available. after written symbol combinations become available.
Milestone 4 begins with a versioned, data-pack-defined symbol catalog and an Milestone 4 delivered a versioned, data-pack-defined symbol catalog and an
immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These
contracts preserve unknown symbol identifiers and store author intent; they do contracts preserve unknown symbol identifiers and store author intent; they do
not interpret symbols into Age generation, which remains Milestone 5 work. not interpret symbols into Age generation, which remains Milestone 5 work.
The Ink Mixer is included in Milestone 4 after book assembly; it
uses paper and Ink Vials to make baseline Link Panels. Adding modifier
ingredients and symbol-driven link properties belongs to Milestone 5.
## Provenance rules ## Provenance rules
+68 -5
View File
@@ -40,6 +40,18 @@ a symbol reference or a baseline link-panel marker. `OrderedWriting` retains up
to 256 entries in exact player-authored order. All three contracts have both to 256 entries in exact player-authored order. All three contracts have both
persistent codecs and bounded network codecs. persistent codecs and bounded network codecs.
Assembled Descriptive Books store `OrderedWriting` schema version 1 alongside
their Age reference. Books assembled in 0.3.7.2 saved writing as a bare list;
that form still loads as version 1 without reordering or dropping unknown IDs.
Older page-free books without writing continue to work unchanged. Copying a
book copies its writing and Age reference; merely copying it never creates a
new Age. The Book Binder creates the Age when its finished output is taken.
The Descriptive Book screen can inspect the ordered pages by turning them from
the bottom corners and identifies unresolved IDs using server-provided symbol
metadata. Until Milestone 5, the
saved symbols are informational only: Age creation uses the existing random
fallback and does not apply them to generation.
Removing a data pack never removes its IDs from saved writing. A missing ID is Removing a data pack never removes its IDs from saved writing. A missing ID is
resolved as client-safe **Unresolved Symbol** display metadata while retaining resolved as client-safe **Unresolved Symbol** display metadata while retaining
the original namespaced ID for diagnostics and later restoration. This is the original namespaced ID for diagnostics and later restoration. This is
@@ -53,11 +65,11 @@ unresolved symbol, and baseline link panel. Symbol and unresolved-symbol pages
retain their namespaced symbol reference; unresolved pages can be inspected and retain their namespaced symbol reference; unresolved pages can be inspected and
removed but are rejected as usable writing until their data pack returns. removed but are rejected as usable writing until their data pack returns.
Only server-side writing operations may create a known symbol Only server-side writing operations may create a known symbol page or link
page or link panel from vanilla paper. The shared `PageItem` creation and validation gates reject panel from vanilla paper. The shared `PageItem` creation and validation gates
new pages for identifiers absent from the server catalog. Later Writing Desk reject new pages for identifiers absent from the server catalog. The Writing
and Book Binder packets must call these gates rather than accepting page Desk refuses to copy an unresolved page, and the Book Binder keeps such a page
contents supplied by a client. removable but will not assemble a book from it until its symbol resolves again.
The item model uses server-assigned custom-model data derived from the symbol The item model uses server-assigned custom-model data derived from the symbol
identifier. Built-in pages reproduce Mystcraft Legacy's four-word poem identifier. Built-in pages reproduce Mystcraft Legacy's four-word poem
@@ -72,6 +84,29 @@ placement. The surface draws the native 30×40 portrait page region from
Legacy's book UI atlas; symbols are layered at a uniform scale, while the Legacy's book UI atlas; symbols are layered at a uniform scale, while the
square item models remain unchanged in inventories. square item models remain unchanged in inventories.
A Notebook holds 27 ordered positions. Its pages, names, gaps, and unresolved
IDs are saved on the item, so moving the Notebook between an inventory, Desk,
and Binder does not rearrange them. A loose Page in a Desk tab exposes just one
position; a Notebook exposes all 27. The Desk has 88 persistent collection
tabs, shown four at a time. Use the up/down tab controls to move by four, put
paper and Ink Vials in their input slots, select a stored page, and take the
copy from the virtual output. Taking it spends one paper and the configured
symbol-copy ink cost. The `AZ`, `ALL`, search, and category controls only change
which stored pages are displayed; the server rechecks the actual source before
copying. Moving a page within the Desk or dragging it into the inventory removes
the original only when the destination accepts it.
The Book Binder takes a leather or empty-Notebook cover, an Age title, one Link
Panel, and optionally up to 27 ordered written Pages. A filled Notebook can be
imported at a chosen strip position; the empty Notebook remains recoverable.
The preview result is virtual. Taking the completed Descriptive Book commits the
inputs once, creates its Age identity, and saves schema-versioned
`OrderedWriting` plus the Age reference on the book. Link-Panel-only books
create random Ages. A written symbol does **not** yet change that generation.
Inputs, title, and pending Age identity survive Binder save/load; breaking the
Binder returns stored inputs. The completed book retains both its writing and
Age identity through item save/load and copying.
## Ink and baseline link panels ## Ink and baseline link panels
An Ink Vial is crafted from two black dye, a glass bottle, and a water bucket. An Ink Vial is crafted from two black dye, a glass bottle, and a water bucket.
@@ -91,3 +126,31 @@ resolved page, then spends paper and ink only when that copy is taken. The
desk's 16-unit ink meter refills from an Ink Vial when needed and returns a desk's 16-unit ink meter refills from an Ink Vial when needed and returns a
vanilla glass bottle. Link-property modifiers are vanilla glass bottle. Link-property modifiers are
deferred beyond this baseline flow. deferred beyond this baseline flow.
The Ink Mixer is the station version of Link Panel preparation. Put paper and
Ink Vials in its inputs and take the Link Panel from its virtual output. Its
16-unit basin refills only when needed and only if the glass-bottle output has
space. The basin and inputs persist through save/load; breaking the Mixer
returns stored items. A Link Panel plus leather also crafts an unwritten
Linking Book. JEI, when installed, displays the Mixer operation and the
writing, binding, and Linking Book crafting flows; it is not required to play.
## Migration and validation
The 0.3.7.2 bare-list `OrderedWriting` form reads as schema version 1 without
changing entry order. Descriptive Books with no writing component keep their
existing Age behavior. Old eight-slot Desks migrate their four collections,
ink supply, paper, bottle return, and legacy output into the 88-tab layout;
saved inputs are never silently discarded. Missing symbol IDs survive page,
Notebook, Binder, and assembled-book persistence instead of being substituted
or erased.
Dedicated-server GameTests exercise codec and item round-trips, legacy data,
workstation NBT reload, break drops, full-inventory refusal, forged menu IDs,
and replayed output requests. World saves and chunk unloads use the same block
entity NBT persistence path as those reload tests. This does not replace a
manual smoke test of loading an older world before updating a production save.
Milestone 4 stops at preserving author intent and offering a playable random
Age fallback. Milestone 5 will define symbol grammar and validation, translate
valid writing into actual Age generation, and introduce symbol and ink modifiers.
+2 -1
View File
@@ -22,6 +22,7 @@ neo_version=21.1.251
ponder_version=1.0.82 ponder_version=1.0.82
dynamic_dimensions_file_id=7707789 dynamic_dimensions_file_id=7707789
badpackets_version=0.8.2 badpackets_version=0.8.2
jei_version=19.57.0.449
# The loader version range can only use the major version of FML as bounds # The loader version range can only use the major version of FML as bounds
loader_version_range=[1,) loader_version_range=[1,)
@@ -35,7 +36,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.3.7.2 mod_version=0.4.0.0
immersive_portals_version=6.0.7 immersive_portals_version=6.0.7
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources. # This should match the base package used for the mod sources.
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus; import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.registry.ModRecipeSerializers;
import net.dnorberg.rivencraft.registry.ModSounds; import net.dnorberg.rivencraft.registry.ModSounds;
import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModBlockEntities; import net.dnorberg.rivencraft.registry.ModBlockEntities;
@@ -43,6 +44,7 @@ public final class Rivencraft {
ModBlockEntities.register(modEventBus); ModBlockEntities.register(modEventBus);
ModItems.register(modEventBus); ModItems.register(modEventBus);
ModMenus.register(modEventBus); ModMenus.register(modEventBus);
ModRecipeSerializers.register(modEventBus);
ModSounds.register(modEventBus); ModSounds.register(modEventBus);
ModCreativeTabs.register(modEventBus); ModCreativeTabs.register(modEventBus);
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock); NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
@@ -0,0 +1,52 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
public final class InkMixerBlock extends BaseEntityBlock {
public static final MapCodec<InkMixerBlock> CODEC = simpleCodec(InkMixerBlock::new);
public InkMixerBlock(Properties properties) { super(properties); }
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new InkMixerBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof InkMixerBlockEntity mixer) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new InkMixerMenu(id, inventory, mixer),
Component.translatable("container.rivencraft.ink_mixer")),
buffer -> buffer.writeBlockPos(pos));
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
@Override protected void onRemove(BlockState state, Level level, BlockPos pos,
BlockState replacement, boolean moving) {
if (!state.is(replacement.getBlock()) && !level.isClientSide
&& level.getBlockEntity(pos) instanceof InkMixerBlockEntity mixer) {
Containers.dropContents(level, pos, mixer);
level.updateNeighbourForOutputSignal(pos, this);
}
super.onRemove(state, level, pos, replacement, moving);
}
}
@@ -105,7 +105,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|| items.get(LINK_PANEL).getCount() != 1) return false; || items.get(LINK_PANEL).getCount() != 1) return false;
for (int slot = FIRST_PAGE; slot < SIZE; slot++) { for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
ItemStack page = items.get(slot); ItemStack page = items.get(slot);
if (!page.isEmpty() && (!validPage(page) || page.getCount() != 1 if (!page.isEmpty() && (!validPage(page) || !PageItem.isUsableWriting(page) || page.getCount() != 1
|| PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false; || PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false;
} }
return true; return true;
@@ -0,0 +1,140 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** Baseline Link Panel mixer. Modifier ingredients belong to the symbol/ink-effects milestone. */
public final class InkMixerBlockEntity extends BlockEntity implements Container {
public static final int INK = 0;
public static final int PAPER = 1;
public static final int BOTTLES = 2;
public static final int SIZE = 3;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int inkRemaining;
public InkMixerBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.INK_MIXER.get(), pos, state);
}
public int inkRemaining() { return inkRemaining; }
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) {
setChanged();
if (slot == BOTTLES) refillEmptyBasin();
}
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
setChanged();
if (slot == INK || slot == BOTTLES) refillEmptyBasin();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return switch (slot) {
case INK -> stack.is(ModItems.INK_VIAL.get());
case PAPER -> stack.is(Items.PAPER);
default -> false;
};
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
private boolean hasBottleSpace() {
ItemStack bottles = items.get(BOTTLES);
return bottles.isEmpty() || bottles.is(Items.GLASS_BOTTLE)
&& bottles.getCount() < bottles.getMaxStackSize();
}
/** No server tick: refill only when ink, bottle space, or consumption changes. */
private void refillEmptyBasin() {
if (level == null || level.isClientSide || inkRemaining != 0 || !hasBottleSpace()) return;
ItemStack vial = items.get(INK);
if (!vial.is(ModItems.INK_VIAL.get())) return;
int amount = Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
if (amount == 0) return;
inkRemaining = amount;
vial.shrink(1);
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
ItemStack bottles = items.get(BOTTLES);
if (bottles.isEmpty()) items.set(BOTTLES, new ItemStack(Items.GLASS_BOTTLE));
else bottles.grow(1);
setChanged();
}
public boolean canCreateLinkPanel() {
if (!items.get(PAPER).is(Items.PAPER)) return false;
int cost = WritingInkService.linkPanelCost();
if (inkRemaining >= cost) return true;
ItemStack vial = items.get(INK);
return vial.is(ModItems.INK_VIAL.get()) && hasBottleSpace()
&& inkRemaining + InkVialItem.remaining(vial) >= cost;
}
/** Commit inputs only when a virtual output is actually taken. */
public boolean consumeLinkPanelMaterials() {
if (!canCreateLinkPanel()) return false;
int cost = WritingInkService.linkPanelCost();
if (inkRemaining < cost) {
ItemStack vial = items.get(INK);
inkRemaining += Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
vial.shrink(1);
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
ItemStack bottles = items.get(BOTTLES);
if (bottles.isEmpty()) items.set(BOTTLES, new ItemStack(Items.GLASS_BOTTLE));
else bottles.grow(1);
}
inkRemaining -= cost;
items.get(PAPER).shrink(1);
if (items.get(PAPER).isEmpty()) items.set(PAPER, ItemStack.EMPTY);
setChanged();
if (inkRemaining == 0) refillEmptyBasin();
return true;
}
@Override public void setLevel(Level level) {
super.setLevel(level);
refillEmptyBasin();
}
@Override public void clearContent() {
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
setChanged();
}
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(tag, items, registries);
inkRemaining = Math.clamp(tag.getInt("InkRemaining"), 0, InkVialItem.CAPACITY);
refillEmptyBasin();
}
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, items, registries);
tag.putInt("InkRemaining", inkRemaining);
}
}
@@ -92,8 +92,8 @@ public final class ClientBookInteractionHandler {
return; return;
} }
minecraft.setScreen(payload.fromStand() minecraft.setScreen(payload.fromStand()
? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits()) ? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits(), payload.writingSymbols())
: new DescriptiveBookScreen(hand, payload.book(), payload.traits())); : new DescriptiveBookScreen(hand, payload.book(), payload.traits(), payload.writingSymbols()));
}); });
} }
} }
@@ -16,6 +16,7 @@ import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.dnorberg.rivencraft.client.screen.NotebookScreen; import net.dnorberg.rivencraft.client.screen.NotebookScreen;
import net.dnorberg.rivencraft.client.screen.WritingDeskScreen; import net.dnorberg.rivencraft.client.screen.WritingDeskScreen;
import net.dnorberg.rivencraft.client.screen.BookBinderScreen; import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
import net.dnorberg.rivencraft.client.screen.InkMixerScreen;
import net.dnorberg.rivencraft.registry.ModMenus; import net.dnorberg.rivencraft.registry.ModMenus;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT) @EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
@@ -34,6 +35,7 @@ public final class ClientEvents {
event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new); event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new);
event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new); event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new);
event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new); event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new);
event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new);
} }
@SubscribeEvent @SubscribeEvent
@@ -13,6 +13,7 @@ import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.PacketDistributor; import net.neoforged.neoforge.network.PacketDistributor;
import org.lwjgl.glfw.GLFW;
/** Legacy-sized binder: cover and result flank a scrolling strip of full page thumbnails. */ /** Legacy-sized binder: cover and result flank a scrolling strip of full page thumbnails. */
public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMenu> { public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMenu> {
@@ -41,6 +42,8 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
inventoryLabelY = 87; inventoryLabelY = 87;
} }
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() { @Override protected void init() {
super.init(); super.init();
titleInput = new EditBox(font, leftPos + 9, topPos + 10, 112, 12, titleInput = new EditBox(font, leftPos + 9, topPos + 10, 112, 12,
@@ -94,6 +97,13 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
} }
graphics.disableScissor(); graphics.disableScissor();
drawScrollEnds(graphics); drawScrollEnds(graphics);
if (mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
if (firstVisible > 0 && mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + 8)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book_binder.previous_pages"), mouseX, mouseY);
else if (firstVisible < maxFirstVisible() && mouseX >= leftPos + STRIP_X + STRIP_WIDTH - 8
&& mouseX < leftPos + STRIP_X + STRIP_WIDTH)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book_binder.next_pages"), mouseX, mouseY);
}
if (pageAtIndex(0).isEmpty() && mouseX >= leftPos + 27 && mouseX < leftPos + 45 if (pageAtIndex(0).isEmpty() && mouseX >= leftPos + 27 && mouseX < leftPos + 45
&& mouseY >= topPos + 26 && mouseY < topPos + 44) { && mouseY >= topPos + 26 && mouseY < topPos + 44) {
graphics.renderTooltip(font, graphics.renderTooltip(font,
@@ -210,6 +220,14 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
} else titleInput.keyPressed(keyCode, scanCode, modifiers); } else titleInput.keyPressed(keyCode, scanCode, modifiers);
return true; return true;
} }
if (keyCode == GLFW.GLFW_KEY_LEFT && firstVisible > 0) {
firstVisible--;
return true;
}
if (keyCode == GLFW.GLFW_KEY_RIGHT && firstVisible < maxFirstVisible()) {
firstVisible++;
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers); return super.keyPressed(keyCode, scanCode, modifiers);
} }
} }
@@ -1,5 +1,7 @@
package net.dnorberg.rivencraft.client.screen; package net.dnorberg.rivencraft.client.screen;
import java.util.List;
import java.util.Locale;
import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeTraits; import net.dnorberg.rivencraft.book.AgeTraits;
import net.dnorberg.rivencraft.client.ClientBookExploreOverlay; import net.dnorberg.rivencraft.client.ClientBookExploreOverlay;
@@ -10,62 +12,78 @@ import net.dnorberg.rivencraft.network.RequestLinkPreviewPayload;
import net.dnorberg.rivencraft.network.LinkPreviewPayload; import net.dnorberg.rivencraft.network.LinkPreviewPayload;
import net.dnorberg.rivencraft.network.ReleaseLinkPreviewPayload; import net.dnorberg.rivencraft.network.ReleaseLinkPreviewPayload;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer; import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer;
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderers; import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderers;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.resources.sounds.SimpleSoundInstance;
import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.FormattedCharSequence; import net.minecraft.util.FormattedCharSequence;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionHand;
import net.neoforged.neoforge.network.PacketDistributor; import net.neoforged.neoforge.network.PacketDistributor;
import org.lwjgl.glfw.GLFW;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.util.Locale;
public final class DescriptiveBookScreen extends Screen implements ExplorableBookScreen { public final class DescriptiveBookScreen extends Screen implements ExplorableBookScreen {
private static final int BOOK_TEXT_COLOR = 0xFF3B281B; private static final int BOOK_TEXT_COLOR = 0xFF3B281B;
private static final ResourceLocation BOOK_TEXTURE = ResourceLocation.fromNamespaceAndPath( private static final int UNRESOLVED_TEXT_COLOR = 0xFF9B302C;
"rivencraft", "textures/gui/linking_book.png" private static final ResourceLocation ENCHANTMENT_FONT = ResourceLocation.withDefaultNamespace("alt");
);
private static final int BOOK_WIDTH = 327; private static final int BOOK_WIDTH = 327;
private static final int BOOK_HEIGHT = 199; private static final int BOOK_HEIGHT = 199;
private static final int TEXTURE_WIDTH = 512; private static final int LINK_PANEL_X = 173;
private static final int TEXTURE_HEIGHT = 312; private static final int LINK_PANEL_Y = 20;
private static final int LINK_PANEL_X = 28; private static final int LINK_PANEL_WIDTH = 132;
private static final int LINK_PANEL_Y = 35; private static final int LINK_PANEL_HEIGHT = 83;
private static final int LINK_PANEL_WIDTH = 126;
private static final int LINK_PANEL_HEIGHT = 86;
private static final Component EXPLORE_ICON = Component.literal("👁"); private static final Component EXPLORE_ICON = Component.literal("👁");
private final AgeReference age; private final AgeReference age;
private final ResourceLocation generationProfile; private final ResourceLocation generationProfile;
private final AgeTraits traits; private final AgeTraits traits;
private final OrderedWriting writing;
private final List<SymbolDisplayMetadata> writingSymbols;
private final boolean pageFree;
private final InteractionHand hand; private final InteractionHand hand;
private final BlockPos bookStandPos; private final BlockPos bookStandPos;
private DestinationPreviewRenderer preview; private DestinationPreviewRenderer preview;
private EditBox titleInput; private EditBox titleInput;
private Button exploreButton; private Button exploreButton;
private Button createButton;
private Button cancelButton;
private int pageIndex;
private boolean movingToExploreOverlay; private boolean movingToExploreOverlay;
public DescriptiveBookScreen(InteractionHand hand, ItemStack book, @Nullable AgeTraits traits) { public DescriptiveBookScreen(InteractionHand hand, ItemStack book, @Nullable AgeTraits traits,
List<SymbolDisplayMetadata> writingSymbols) {
super(book.getHoverName()); super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get()); age = book.get(ModDataComponents.AGE_REFERENCE.get());
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get()); generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
this.traits = traits; this.traits = traits;
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
this.writingSymbols = List.copyOf(writingSymbols);
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
this.hand = hand; this.hand = hand;
bookStandPos = null; bookStandPos = null;
} }
public DescriptiveBookScreen(BlockPos bookStandPos, ItemStack book, @Nullable AgeTraits traits) { public DescriptiveBookScreen(BlockPos bookStandPos, ItemStack book, @Nullable AgeTraits traits,
List<SymbolDisplayMetadata> writingSymbols) {
super(book.getHoverName()); super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get()); age = book.get(ModDataComponents.AGE_REFERENCE.get());
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get()); generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
this.traits = traits; this.traits = traits;
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
this.writingSymbols = List.copyOf(writingSymbols);
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
hand = InteractionHand.MAIN_HAND; hand = InteractionHand.MAIN_HAND;
this.bookStandPos = bookStandPos.immutable(); this.bookStandPos = bookStandPos.immutable();
} }
@@ -75,19 +93,22 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
int left = (width - BOOK_WIDTH) / 2; int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2; int top = (height - BOOK_HEIGHT) / 2;
if (age == null) { if (age == null) {
int rightPage = left + 177; int leftPage = left + 40;
titleInput = new EditBox(font, rightPage, top + 69, 124, 20, titleInput = new EditBox(font, leftPage, top + 69, 110, 20,
Component.translatable("screen.rivencraft.age.title")); Component.translatable("screen.rivencraft.age.title"));
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH); titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.translatable("screen.rivencraft.age.title_hint")); titleInput.setHint(Component.translatable("screen.rivencraft.age.title_hint"));
addRenderableWidget(titleInput); addRenderableWidget(titleInput);
setInitialFocus(titleInput); setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.age.create"), button -> createAge()) createButton = addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.age.create"), button -> createAge())
.bounds(rightPage, top + 100, 59, 20) .bounds(leftPage, top + 100, 53, 20)
.build()); .build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose()) cancelButton = addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(rightPage + 65, top + 100, 59, 20) .bounds(leftPage + 57, top + 100, 53, 20)
.build()); .build());
titleInput.visible = pageIndex == 0;
createButton.visible = pageIndex == 0;
cancelButton.visible = pageIndex == 0;
} else { } else {
boolean requestPreview = preview == null; boolean requestPreview = preview == null;
if (requestPreview) { if (requestPreview) {
@@ -96,8 +117,10 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
if (preview.supportsExploration()) { if (preview.supportsExploration()) {
exploreButton = addRenderableWidget(Button.builder( exploreButton = addRenderableWidget(Button.builder(
EXPLORE_ICON, button -> enterExploreOverlay()) EXPLORE_ICON, button -> enterExploreOverlay())
.bounds(left + 81, top + 145, 20, 20).build()); .bounds(left + 229, top + 145, 20, 20)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.book.explore"))).build());
exploreButton.active = bookStandPos == null; exploreButton.active = bookStandPos == null;
exploreButton.visible = pageIndex == 0;
} }
if (requestPreview) { if (requestPreview) {
PacketDistributor.sendToServer(new RequestLinkPreviewPayload( PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
@@ -132,50 +155,111 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
renderBook(graphics, partialTick); renderBook(graphics, partialTick);
super.render(graphics, mouseX, mouseY, partialTick); super.render(graphics, mouseX, mouseY, partialTick);
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
if (mouseY >= top + 164 && mouseY < top + 195) {
if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.previous_page"), mouseX, mouseY);
else if (pageIndex < pageCount() && mouseX >= left + 293 && mouseX < left + 320)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.next_page"), mouseX, mouseY);
}
} }
private void renderBook(GuiGraphics graphics, float partialTick) { private void renderBook(GuiGraphics graphics, float partialTick) {
int left = (width - BOOK_WIDTH) / 2; int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2; int top = (height - BOOK_HEIGHT) / 2;
graphics.blit(BOOK_TEXTURE, left, top, BOOK_WIDTH, BOOK_HEIGHT, 0, 0, LegacyBookArtwork.drawCover(graphics, left, top, true);
TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT);
if (pageIndex > 0) {
LegacyBookArtwork.drawLeftPage(graphics, left, top);
}
if (pageIndex == 0) {
if (age != null && preview != null) {
preview.render(graphics, left + LINK_PANEL_X, top + LINK_PANEL_Y,
LINK_PANEL_WIDTH, LINK_PANEL_HEIGHT, partialTick);
}
LegacyBookArtwork.drawPanelPage(graphics, left, top);
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
renderFirstPageText(graphics, left, top);
graphics.disableScissor();
} else if (pageIndex < pageCount()) {
LegacyBookArtwork.drawFullPage(graphics, left, top);
renderWrittenPage(graphics, left, top);
}
String counter = pageIndex + "/" + pageCount();
graphics.drawString(font, counter, left + 165 - font.width(counter) / 2,
top + 185, 0xFF000000, false);
}
private int pageCount() {
// Older page-free Descriptive Books do not have a BOOK_WRITING component.
return Math.max(1, writing.entries().size());
}
private void renderFirstPageText(GuiGraphics graphics, int left, int top) {
int x = left + 40;
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.title"),
x, top + 40, 110);
if (age == null) { if (age == null) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.name_prompt"), drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.name_prompt"),
left + 177, top + 45, 124); x, top + 52, 110);
} else { return;
preview.render(graphics, left + LINK_PANEL_X, top + LINK_PANEL_Y, LINK_PANEL_WIDTH, LINK_PANEL_HEIGHT, partialTick);
int detailsY = drawWrappedWithoutShadow(
graphics, Component.literal(age.name()), left + 177, top + 43, 124) + 3;
detailsY = Math.max(detailsY, top + 72);
if (generationProfile != null) {
Component profileName = generationProfile.getNamespace().equals("rivencraft")
? Component.translatable("generator.rivencraft." + generationProfile.getPath())
: Component.literal(generationProfile.toString());
detailsY = drawWrappedWithoutShadow(graphics,
Component.translatable("screen.rivencraft.age.generator", profileName),
left + 177, detailsY, 124) + 2;
}
if (traits != null) {
detailsY = drawTrait(graphics, "biomes", traitName("biome_distribution", traits.biomeDistribution()),
left + 177, detailsY);
detailsY = drawTrait(graphics, "lighting", traitName("lighting", traits.lighting()),
left + 177, detailsY);
detailsY = drawTrait(graphics, "weather", traitName("weather", traits.weather()),
left + 177, detailsY);
Component dayLength = traits.hasStoppedCelestialCycle()
? Component.translatable("trait.rivencraft.day_length.stopped")
: Component.literal(String.format(Locale.ROOT, "%.2fx", traits.celestialPeriod()));
detailsY = drawTrait(graphics, "day_length", dayLength, left + 177, detailsY);
detailsY = drawTrait(graphics, "celestial_angle",
Component.literal(String.format(Locale.ROOT, "%.0f°", traits.celestialAngle())),
left + 177, detailsY);
drawTrait(graphics, "celestial_phase",
Component.literal(String.format(Locale.ROOT, "%.0f%%", traits.celestialPhase() * 100.0F)),
left + 177, detailsY);
}
} }
int detailsY = drawWrappedWithoutShadow(graphics, Component.literal(age.name()),
x + 10, top + 52, 100) + 10;
if (generationProfile != null) {
Component profileName = generationProfile.getNamespace().equals("rivencraft")
? Component.translatable("generator.rivencraft." + generationProfile.getPath())
: Component.literal(generationProfile.toString());
detailsY = drawWrappedWithoutShadow(graphics,
Component.translatable("screen.rivencraft.age.generator", profileName),
x, detailsY, 110) + 2;
}
if (traits != null) {
detailsY = drawTrait(graphics, "biomes", traitName("biome_distribution", traits.biomeDistribution()), x, detailsY);
detailsY = drawTrait(graphics, "lighting", traitName("lighting", traits.lighting()), x, detailsY);
detailsY = drawTrait(graphics, "weather", traitName("weather", traits.weather()), x, detailsY);
Component dayLength = traits.hasStoppedCelestialCycle()
? Component.translatable("trait.rivencraft.day_length.stopped")
: Component.literal(String.format(Locale.ROOT, "%.2fx", traits.celestialPeriod()));
detailsY = drawTrait(graphics, "day_length", dayLength, x, detailsY);
detailsY = drawTrait(graphics, "celestial_angle",
Component.literal(String.format(Locale.ROOT, "%.0f°", traits.celestialAngle())), x, detailsY);
drawTrait(graphics, "celestial_phase",
Component.literal(String.format(Locale.ROOT, "%.0f%%", traits.celestialPhase() * 100.0F)), x, detailsY);
}
}
private void renderWrittenPage(GuiGraphics graphics, int left, int top) {
WritingEntry entry = writing.entries().get(pageIndex);
int x = left + 176;
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
drawWrappedWithoutShadow(graphics, Component.translatable("item.rivencraft.page.link_panel"),
x, top + 39, 126);
return;
}
int symbolIndex = (int) writing.entries().subList(0, pageIndex + 1).stream()
.filter(candidate -> candidate.kind() == WritingEntry.Kind.SYMBOL).count() - 1;
SymbolDisplayMetadata display = symbolIndex < writingSymbols.size()
? writingSymbols.get(symbolIndex) : SymbolDisplayMetadata.unresolved(entry.symbol().orElseThrow());
int color = display.resolved() ? BOOK_TEXT_COLOR : UNRESOLVED_TEXT_COLOR;
drawWrappedWithoutShadow(graphics, Component.translatable(display.nameTranslationKey()),
x, top + 39, 126, color);
drawWrappedWithoutShadow(graphics, Component.literal(display.id().toString()),
x, top + 64, 126, color);
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.pages_not_applied"),
x, top + 137, 126);
}
private void turnPage(int direction) {
int nextPage = Math.max(0, Math.min(pageCount(), pageIndex + direction));
if (nextPage == pageIndex) return;
pageIndex = nextPage;
minecraft.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.BOOK_PAGE_TURN, 1.0F, 0.65F));
if (titleInput != null) titleInput.visible = pageIndex == 0;
if (createButton != null) createButton.visible = pageIndex == 0;
if (cancelButton != null) cancelButton.visible = pageIndex == 0;
if (exploreButton != null) exploreButton.visible = pageIndex == 0;
} }
@Override @Override
@@ -201,14 +285,25 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
resetExploration(); resetExploration();
return true; return true;
} }
if (age != null && button == 0) { if (button == 0) {
int panelLeft = (width - BOOK_WIDTH) / 2 + LINK_PANEL_X; int left = (width - BOOK_WIDTH) / 2;
int panelTop = (height - BOOK_HEIGHT) / 2 + LINK_PANEL_Y; int top = (height - BOOK_HEIGHT) / 2;
if (mouseX >= panelLeft && mouseX < panelLeft + LINK_PANEL_WIDTH if (pageIndex == 0 && age != null
&& mouseY >= panelTop && mouseY < panelTop + LINK_PANEL_HEIGHT) { && mouseX >= left + LINK_PANEL_X && mouseX < left + LINK_PANEL_X + LINK_PANEL_WIDTH
&& mouseY >= top + LINK_PANEL_Y && mouseY < top + LINK_PANEL_Y + LINK_PANEL_HEIGHT) {
link(); link();
return true; return true;
} }
if (mouseY >= top + 164 && mouseY < top + 195) {
if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35) {
turnPage(-1);
return true;
}
if (pageIndex < pageCount() && mouseX >= left + 293 && mouseX < left + 320) {
turnPage(1);
return true;
}
}
} }
return super.mouseClicked(mouseX, mouseY, button); return super.mouseClicked(mouseX, mouseY, button);
} }
@@ -224,6 +319,10 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
@Override @Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) { public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (titleInput == null || !titleInput.isFocused()) {
if (keyCode == GLFW.GLFW_KEY_LEFT && pageIndex > 0) { turnPage(-1); return true; }
if (keyCode == GLFW.GLFW_KEY_RIGHT && pageIndex < pageCount()) { turnPage(1); return true; }
}
return preview != null && preview.keyPressed(keyCode, scanCode) return preview != null && preview.keyPressed(keyCode, scanCode)
|| super.keyPressed(keyCode, scanCode, modifiers); || super.keyPressed(keyCode, scanCode, modifiers);
} }
@@ -268,7 +367,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
int top = (height - BOOK_HEIGHT) / 2; int top = (height - BOOK_HEIGHT) / 2;
graphics.drawCenteredString(font, graphics.drawCenteredString(font,
Component.translatable("screen.rivencraft.book.return_from_explore"), Component.translatable("screen.rivencraft.book.return_from_explore"),
left + 91, top + 154, BOOK_TEXT_COLOR); left + 239, top + 154, BOOK_TEXT_COLOR);
} }
@Override @Override
@@ -317,7 +416,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
private int drawTrait(GuiGraphics graphics, String key, Component value, int x, int y) { private int drawTrait(GuiGraphics graphics, String key, Component value, int x, int y) {
return drawWrappedWithoutShadow(graphics, return drawWrappedWithoutShadow(graphics,
Component.translatable("screen.rivencraft.age.trait." + key, value), x, y, 124) + 2; Component.translatable("screen.rivencraft.age.trait." + key, value), x, y, 110) + 2;
} }
private static Component traitName(String group, Enum<?> value) { private static Component traitName(String group, Enum<?> value) {
@@ -326,8 +425,14 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
} }
private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth) { private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth) {
for (FormattedCharSequence line : font.split(text, maxWidth)) { return drawWrappedWithoutShadow(graphics, text, x, y, maxWidth, BOOK_TEXT_COLOR);
graphics.drawString(font, line, x, y, BOOK_TEXT_COLOR, false); }
private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth, int color) {
Component displayed = pageFree && age != null && pageIndex == 0
? text.copy().withStyle(style -> style.withFont(ENCHANTMENT_FONT)) : text;
for (FormattedCharSequence line : font.split(displayed, maxWidth)) {
graphics.drawString(font, line, x, y, color, false);
y += font.lineHeight; y += font.lineHeight;
} }
return y; return y;
@@ -0,0 +1,52 @@
package net.dnorberg.rivencraft.client.screen;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Inventory;
/** Legacy Ink Mixer atlas with a server-synced, sixteen-use basin. */
public final class InkMixerScreen extends AbstractContainerScreen<InkMixerMenu> {
private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/ink_mixer_legacy.png");
public InkMixerScreen(InkMixerMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
imageWidth = 176;
imageHeight = 181;
inventoryLabelY = 87;
}
@Override public boolean isPauseScreen() { return false; }
@Override public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
renderBg(graphics, partialTick, mouseX, mouseY);
}
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
graphics.blit(BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
int centerX = leftPos + 88;
int centerY = topPos + 49;
int fillTop = centerY + 26 - Math.round(menu.inkRemaining() / (float) InkVialItem.CAPACITY * 52);
if (menu.inkRemaining() > 0) {
for (int y = Math.max(centerY - 26, fillTop); y <= centerY + 26; y++) {
int halfWidth = (int) Math.sqrt(26 * 26 - (y - centerY) * (y - centerY));
graphics.fill(centerX - halfWidth, y, centerX + halfWidth + 1, y + 1, 0xFF241228);
}
}
}
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
}
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
renderTooltip(graphics, mouseX, mouseY);
int x = mouseX - (leftPos + 88);
int y = mouseY - (topPos + 49);
if (x * x + y * y <= 26 * 26)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.ink_mixer.ink",
menu.inkRemaining(), InkVialItem.CAPACITY), mouseX, mouseY);
}
}
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.resources.ResourceLocation;
/** Original Mystcraft Legacy book atlases and page composition (LGPL-3.0,
* copyright XCompWiz, revision 9bc8ddc061845df0cd5ea47f4fdef773cff786ae). */
final class LegacyBookArtwork {
private static final int ATLAS_SIZE = 256;
private static final ResourceLocation COVER = texture("bookui_cover.png");
private static final ResourceLocation LEFT_PAGE = texture("bookui_pagel.png");
private static final ResourceLocation RIGHT_PANEL = texture("bookui_pager.png");
private static final ResourceLocation RIGHT_FULL = texture("bookui_rpage_full.png");
private LegacyBookArtwork() {}
static void drawCover(GuiGraphics 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(GuiGraphics graphics, int left, int top) {
blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
}
static void drawPanelPage(GuiGraphics graphics, int left, int top) {
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
}
static void drawFullPage(GuiGraphics graphics, int left, int top) {
blit(graphics, RIGHT_FULL, left + 163, top, 156, 195, 0, 0);
}
private static ResourceLocation texture(String filename) {
return ResourceLocation.fromNamespaceAndPath("rivencraft", "textures/gui/" + filename);
}
private static void blit(GuiGraphics graphics, ResourceLocation texture, int x, int y,
int width, int height, int u, int v) {
graphics.blit(texture, x, y, width, height, u, v, width, height, ATLAS_SIZE, ATLAS_SIZE);
}
}
@@ -16,28 +16,28 @@ import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.resources.sounds.SimpleSoundInstance;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation; import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.FormattedCharSequence; import net.minecraft.util.FormattedCharSequence;
import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.PacketDistributor; import net.neoforged.neoforge.network.PacketDistributor;
import org.lwjgl.glfw.GLFW;
public final class LinkingBookScreen extends Screen implements ExplorableBookScreen { public final class LinkingBookScreen extends Screen implements ExplorableBookScreen {
private static final int BOOK_TEXT_COLOR = 0xFF3B281B; private static final int BOOK_TEXT_COLOR = 0xFF3B281B;
private static final ResourceLocation BOOK_TEXTURE = ResourceLocation.fromNamespaceAndPath(
"rivencraft", "textures/gui/linking_book.png"
);
private static final int BOOK_WIDTH = 327; private static final int BOOK_WIDTH = 327;
private static final int BOOK_HEIGHT = 199; private static final int BOOK_HEIGHT = 199;
private static final int TEXTURE_WIDTH = 512; private static final int PREVIEW_X = 173;
private static final int TEXTURE_HEIGHT = 312; private static final int PREVIEW_Y = 20;
private static final int PREVIEW_X = 28; private static final int PREVIEW_WIDTH = 132;
private static final int PREVIEW_Y = 35; private static final int PREVIEW_HEIGHT = 83;
private static final int PREVIEW_WIDTH = 126;
private static final int PREVIEW_HEIGHT = 86;
private static final Component EXPLORE_ICON = Component.literal("👁"); private static final Component EXPLORE_ICON = Component.literal("👁");
private final InteractionHand hand; private final InteractionHand hand;
@@ -47,6 +47,9 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
private DestinationPreviewRenderer preview; private DestinationPreviewRenderer preview;
private EditBox titleInput; private EditBox titleInput;
private Button exploreButton; private Button exploreButton;
private Button bindButton;
private Button cancelButton;
private int pageIndex;
private boolean movingToExploreOverlay; private boolean movingToExploreOverlay;
public LinkingBookScreen(InteractionHand hand, ItemStack book) { public LinkingBookScreen(InteractionHand hand, ItemStack book) {
@@ -70,19 +73,22 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
int left = (width - BOOK_WIDTH) / 2; int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2; int top = (height - BOOK_HEIGHT) / 2;
if (target == null) { if (target == null) {
int rightPage = left + 177; int leftPage = left + 40;
titleInput = new EditBox(font, rightPage, top + 69, 124, 20, titleInput = new EditBox(font, leftPage, top + 69, 110, 20,
Component.translatable("screen.rivencraft.book.title")); Component.translatable("screen.rivencraft.book.title"));
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH); titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.translatable("screen.rivencraft.book.title_hint")); titleInput.setHint(Component.translatable("screen.rivencraft.book.title_hint"));
addRenderableWidget(titleInput); addRenderableWidget(titleInput);
setInitialFocus(titleInput); setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.book.bind"), button -> bind()) bindButton = addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.book.bind"), button -> bind())
.bounds(rightPage, top + 100, 59, 20) .bounds(leftPage, top + 100, 53, 20)
.build()); .build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose()) cancelButton = addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(rightPage + 65, top + 100, 59, 20) .bounds(leftPage + 57, top + 100, 53, 20)
.build()); .build());
titleInput.visible = pageIndex == 0;
bindButton.visible = pageIndex == 0;
cancelButton.visible = pageIndex == 0;
} else { } else {
boolean requestPreview = preview == null; boolean requestPreview = preview == null;
if (requestPreview) { if (requestPreview) {
@@ -91,8 +97,10 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
if (preview.supportsExploration()) { if (preview.supportsExploration()) {
exploreButton = addRenderableWidget(Button.builder( exploreButton = addRenderableWidget(Button.builder(
EXPLORE_ICON, button -> enterExploreOverlay()) EXPLORE_ICON, button -> enterExploreOverlay())
.bounds(left + 81, top + 145, 20, 20).build()); .bounds(left + 229, top + 145, 20, 20)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.book.explore"))).build());
exploreButton.active = bookStandPos == null; exploreButton.active = bookStandPos == null;
exploreButton.visible = pageIndex == 0;
} }
if (requestPreview) { if (requestPreview) {
PacketDistributor.sendToServer(new RequestLinkPreviewPayload( PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
@@ -128,33 +136,55 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
renderBook(graphics, partialTick); renderBook(graphics, partialTick);
super.render(graphics, mouseX, mouseY, partialTick); super.render(graphics, mouseX, mouseY, partialTick);
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
if (mouseY >= top + 164 && mouseY < top + 195) {
if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.previous_page"), mouseX, mouseY);
else if (pageIndex == 0 && mouseX >= left + 293 && mouseX < left + 320)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.next_page"), mouseX, mouseY);
}
} }
private void renderBook(GuiGraphics graphics, float partialTick) { private void renderBook(GuiGraphics graphics, float partialTick) {
int left = (width - BOOK_WIDTH) / 2; int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2; int top = (height - BOOK_HEIGHT) / 2;
graphics.blit(BOOK_TEXTURE, left, top, BOOK_WIDTH, BOOK_HEIGHT, 0, 0, LegacyBookArtwork.drawCover(graphics, left, top, false);
TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT); if (pageIndex > 0) {
LegacyBookArtwork.drawLeftPage(graphics, left, top);
if (target == null) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.book.name_prompt"),
left + 177, top + 45, 124);
} else { } else {
int previewLeft = left + PREVIEW_X; if (target != null && preview != null) {
int previewTop = top + PREVIEW_Y; preview.render(graphics, left + PREVIEW_X, top + PREVIEW_Y,
preview.render(graphics, previewLeft, previewTop, PREVIEW_WIDTH, PREVIEW_HEIGHT, partialTick); PREVIEW_WIDTH, PREVIEW_HEIGHT, partialTick);
}
int rightCenter = left + 239; LegacyBookArtwork.drawPanelPage(graphics, left, top);
drawWrappedWithoutShadow(graphics, title, left + 177, top + 43, 124); graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
BlockPos pos = target.location().pos(); if (target == null) {
drawCenteredWithoutShadow(graphics, drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.book.name_prompt"),
LinkingBookItem.destinationName(book, target), rightCenter, top + 76); left + 40, top + 43, 110);
drawCenteredWithoutShadow(graphics, } else {
Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()), rightCenter, top + 89); int x = left + 40;
drawCenteredWithoutShadow(graphics, Component customName = book.get(DataComponents.CUSTOM_NAME);
Component.translatable("screen.rivencraft.book.click_to_link"), rightCenter, top + 119); boolean returnLink = customName != null
&& customName.getContents() instanceof TranslatableContents translated
&& translated.getKey().equals("item.rivencraft.linking_book.return");
if (!returnLink) {
drawWrappedWithoutShadow(graphics, title, x, top + 40, 110);
}
int destinationY = returnLink ? top + 40 : top + 58;
drawWrappedWithoutShadow(graphics, LinkingBookItem.destinationName(book, target),
x + 10, destinationY, 100);
BlockPos pos = target.location().pos();
drawWrappedWithoutShadow(graphics,
Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()),
x, destinationY + 28, 110);
}
graphics.disableScissor();
} }
String counter = pageIndex + "/1";
graphics.drawString(font, counter, left + 165 - font.width(counter) / 2,
top + 185, 0xFF000000, false);
} }
@Override @Override
@@ -163,18 +193,39 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
resetExploration(); resetExploration();
return true; return true;
} }
if (target != null && button == 0) { if (button == 0) {
int left = (width - BOOK_WIDTH) / 2 + PREVIEW_X; int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2 + PREVIEW_Y; int top = (height - BOOK_HEIGHT) / 2;
if (mouseX >= left && mouseX < left + PREVIEW_WIDTH if (pageIndex == 0 && target != null
&& mouseY >= top && mouseY < top + PREVIEW_HEIGHT) { && mouseX >= left + PREVIEW_X && mouseX < left + PREVIEW_X + PREVIEW_WIDTH
&& mouseY >= top + PREVIEW_Y && mouseY < top + PREVIEW_Y + PREVIEW_HEIGHT) {
link(); link();
return true; return true;
} }
if (mouseY >= top + 164 && mouseY < top + 195) {
if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35) {
turnPage(0);
return true;
}
if (pageIndex == 0 && mouseX >= left + 293 && mouseX < left + 320) {
turnPage(1);
return true;
}
}
} }
return super.mouseClicked(mouseX, mouseY, button); return super.mouseClicked(mouseX, mouseY, button);
} }
private void turnPage(int page) {
if (pageIndex == page) return;
pageIndex = page;
minecraft.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.BOOK_PAGE_TURN, 1.0F, 0.65F));
if (titleInput != null) titleInput.visible = pageIndex == 0;
if (bindButton != null) bindButton.visible = pageIndex == 0;
if (cancelButton != null) cancelButton.visible = pageIndex == 0;
if (exploreButton != null) exploreButton.visible = pageIndex == 0;
}
@Override @Override
public void removed() { public void removed() {
if (preview != null && !movingToExploreOverlay) { if (preview != null && !movingToExploreOverlay) {
@@ -197,6 +248,10 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
@Override @Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) { public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (titleInput == null || !titleInput.isFocused()) {
if (keyCode == GLFW.GLFW_KEY_LEFT && pageIndex > 0) { turnPage(0); return true; }
if (keyCode == GLFW.GLFW_KEY_RIGHT && pageIndex == 0) { turnPage(1); return true; }
}
return preview != null && preview.keyPressed(keyCode, scanCode) return preview != null && preview.keyPressed(keyCode, scanCode)
|| super.keyPressed(keyCode, scanCode, modifiers); || super.keyPressed(keyCode, scanCode, modifiers);
} }
@@ -247,7 +302,7 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
int top = (height - BOOK_HEIGHT) / 2; int top = (height - BOOK_HEIGHT) / 2;
drawCenteredWithoutShadow(graphics, drawCenteredWithoutShadow(graphics,
Component.translatable("screen.rivencraft.book.return_from_explore"), Component.translatable("screen.rivencraft.book.return_from_explore"),
left + 91, top + 154); left + 239, top + 154);
} }
@Override @Override
@@ -47,6 +47,8 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
inventoryLabelY = 123; inventoryLabelY = 123;
} }
@Override public boolean isPauseScreen() { return false; }
@Override @Override
protected void init() { protected void init() {
super.init(); super.init();
@@ -15,12 +15,14 @@ import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.Slot; import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import org.lwjgl.glfw.GLFW;
/** Legacy desk atlas layout, with four visible tabs out of the desk's 88 collections. */ /** Legacy desk atlas layout, with four visible tabs out of the desk's 88 collections. */
public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> { public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> {
@@ -60,12 +62,15 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
titleLabelX = 222; titleLabelX = 222;
} }
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() { @Override protected void init() {
super.init(); super.init();
sortButton = addRenderableWidget(Button.builder(Component.literal("AZ"), button -> { sortButton = addRenderableWidget(Button.builder(Component.literal("AZ"), button -> {
sortAlphabetically = !sortAlphabetically; sortAlphabetically = !sortAlphabetically;
scrollRows = 0; scrollRows = 0;
}).bounds(leftPos + 58, topPos, 20, 18).build()); }).bounds(leftPos + 58, topPos, 20, 18)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.sort"))).build());
allButton = addRenderableWidget(Button.builder(Component.literal("ALL"), button -> { allButton = addRenderableWidget(Button.builder(Component.literal("ALL"), button -> {
allMode = !allMode; allMode = !allMode;
scrollRows = 0; scrollRows = 0;
@@ -75,7 +80,8 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
if (minecraft != null && minecraft.gameMode != null) if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.PAGE_BUTTON_BASE); minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.PAGE_BUTTON_BASE);
} }
}).bounds(leftPos + 80, topPos, 28, 18).build()); }).bounds(leftPos + 80, topPos, 28, 18)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.all"))).build());
search = new EditBox(font, leftPos + 110, topPos, 101, 18, search = new EditBox(font, leftPos + 110, topPos, 101, 18,
Component.translatable("screen.rivencraft.writing_desk.search")); Component.translatable("screen.rivencraft.writing_desk.search"));
search.setMaxLength(64); search.setMaxLength(64);
@@ -203,8 +209,17 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
} }
if (mouseX >= leftPos + 213 && mouseX < leftPos + 231 if (mouseX >= leftPos + 213 && mouseX < leftPos + 231
&& mouseY >= topPos && mouseY < topPos + 18) { && mouseY >= topPos && mouseY < topPos + 18) {
graphics.renderTooltip(font, Component.literal(selectedCategory == null graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.category",
? "All categories" : selectedCategory.toString()), mouseX, mouseY); selectedCategory == null
? Component.translatable("screen.rivencraft.writing_desk.all_categories")
: Component.literal(selectedCategory.toString())), mouseX, mouseY);
}
if (mouseX >= leftPos && mouseX < leftPos + 58) {
int localY = mouseY - topPos;
if (localY >= 20 && localY < 29)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.previous_tabs"), mouseX, mouseY);
else if (localY >= 177 && localY < 186)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.next_tabs"), mouseX, mouseY);
} }
} }
@@ -445,6 +460,16 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
} }
return true; return true;
} }
if (minecraft != null && minecraft.gameMode != null) {
if (keyCode == GLFW.GLFW_KEY_PAGE_UP) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.PREVIOUS_TABS);
return true;
}
if (keyCode == GLFW.GLFW_KEY_PAGE_DOWN) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.NEXT_TABS);
return true;
}
}
return super.keyPressed(keyCode, scanCode, modifiers); return super.keyPressed(keyCode, scanCode, modifiers);
} }
} }
@@ -0,0 +1,119 @@
package net.dnorberg.rivencraft.compat.jei;
import java.util.List;
import mezz.jei.api.IModPlugin;
import mezz.jei.api.JeiPlugin;
import mezz.jei.api.constants.VanillaTypes;
import mezz.jei.api.gui.builder.IRecipeLayoutBuilder;
import mezz.jei.api.gui.ingredient.IRecipeSlotsView;
import mezz.jei.api.helpers.IGuiHelper;
import mezz.jei.api.recipe.IFocusGroup;
import mezz.jei.api.recipe.RecipeType;
import mezz.jei.api.recipe.category.AbstractRecipeCategory;
import mezz.jei.api.registration.IRecipeCatalystRegistration;
import mezz.jei.api.registration.IRecipeCategoryRegistration;
import mezz.jei.api.registration.IRecipeRegistration;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Loaded by JEI's plugin scanner only when JEI is installed. No common mod class references this type. */
@JeiPlugin
public final class RivencraftJeiPlugin implements IModPlugin {
private static final RecipeType<WritingGuide> WRITING =
RecipeType.create(Rivencraft.MOD_ID, "writing", WritingGuide.class);
private static final RecipeType<InkMixerGuide> INK_MIXER =
RecipeType.create(Rivencraft.MOD_ID, "ink_mixer", InkMixerGuide.class);
@Override
public ResourceLocation getPluginUid() {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "jei_writing");
}
@Override
public void registerCategories(IRecipeCategoryRegistration registration) {
IGuiHelper guiHelper = registration.getJeiHelpers().getGuiHelper();
registration.addRecipeCategories(new WritingCategory(guiHelper), new InkMixerCategory(guiHelper));
}
@Override
public void registerRecipes(IRecipeRegistration registration) {
ItemStack panel = PageItem.createLinkPanelPage();
registration.addRecipes(WRITING, List.of(
new WritingGuide(List.of(new ItemStack(Items.PAPER), new ItemStack(ModItems.INK_VIAL.get())),
panel, "jei.rivencraft.writing.link_panel_hand"),
new WritingGuide(List.of(panel, new ItemStack(Items.PAPER), new ItemStack(ModItems.INK_VIAL.get())),
panel, "jei.rivencraft.writing.copy"),
new WritingGuide(List.of(new ItemStack(Items.LEATHER), panel),
new ItemStack(ModItems.DESCRIPTIVE_BOOK.get()), "jei.rivencraft.writing.bind"),
new WritingGuide(List.of(panel, new ItemStack(Items.LEATHER)),
new ItemStack(ModItems.LINKING_BOOK.get()), "jei.rivencraft.writing.linking_book")
));
registration.addRecipes(INK_MIXER, List.of(new InkMixerGuide(panel)));
}
@Override
public void registerRecipeCatalysts(IRecipeCatalystRegistration registration) {
registration.addRecipeCatalysts(WRITING, ModItems.INK_VIAL.get(),
ModItems.WRITING_DESK.get(), ModItems.BOOK_BINDER.get());
registration.addRecipeCatalysts(INK_MIXER, ModItems.INK_MIXER.get());
}
private record WritingGuide(List<ItemStack> inputs, ItemStack output, String instructionKey) {}
private record InkMixerGuide(ItemStack panel) {}
private static final class InkMixerCategory extends AbstractRecipeCategory<InkMixerGuide> {
InkMixerCategory(IGuiHelper guiHelper) {
super(INK_MIXER, Component.translatable("jei.rivencraft.ink_mixer"),
guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK,
new ItemStack(ModItems.INK_MIXER.get())), 138, 80);
}
@Override
public void setRecipe(IRecipeLayoutBuilder builder, InkMixerGuide recipe, IFocusGroup focuses) {
builder.addInputSlot(2, 3).setStandardSlotBackground().addItemStack(new ItemStack(Items.PAPER));
builder.addInputSlot(23, 3).setStandardSlotBackground()
.addItemStack(new ItemStack(ModItems.INK_VIAL.get()));
builder.addOutputSlot(112, 3).setOutputSlotBackground().addItemStack(recipe.panel());
builder.addOutputSlot(112, 46).setStandardSlotBackground()
.addItemStack(new ItemStack(Items.GLASS_BOTTLE));
}
@Override
public void draw(InkMixerGuide recipe, IRecipeSlotsView slots, GuiGraphics graphics,
double mouseX, double mouseY) {
graphics.drawWordWrap(Minecraft.getInstance().font,
Component.translatable("jei.rivencraft.ink_mixer.usage"), 2, 27, 104, 0x404040);
}
}
private static final class WritingCategory extends AbstractRecipeCategory<WritingGuide> {
WritingCategory(IGuiHelper guiHelper) {
super(WRITING, Component.translatable("jei.rivencraft.writing"),
guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK,
new ItemStack(ModItems.WRITING_DESK.get())), 138, 80);
}
@Override
public void setRecipe(IRecipeLayoutBuilder builder, WritingGuide recipe, IFocusGroup focuses) {
for (int index = 0; index < recipe.inputs().size(); index++) {
builder.addInputSlot(2 + index * 21, 3).setStandardSlotBackground()
.addItemStack(recipe.inputs().get(index));
}
builder.addOutputSlot(112, 3).setOutputSlotBackground().addItemStack(recipe.output());
}
@Override
public void draw(WritingGuide recipe, IRecipeSlotsView slots, GuiGraphics graphics,
double mouseX, double mouseY) {
graphics.drawWordWrap(Minecraft.getInstance().font,
Component.translatable(recipe.instructionKey()), 2, 27, 134, 0x404040);
}
}
}
@@ -74,6 +74,25 @@ public final class BookBinderGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void missingSymbolPageSurvivesReloadButCannotBeAssembled(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setTitle("Recoverable Writing");
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
SymbolReference missing = new SymbolReference(ResourceLocation.parse("example:removed_symbol"));
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, PageItem.createRecoveredPage(missing));
var saved = binder.saveWithoutMetadata(helper.getLevel().registryAccess());
var restored = new BookBinderBlockEntity(binder.getBlockPos(), binder.getBlockState());
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
helper.assertValueEqual(PageItem.content(restored.getItem(BookBinderBlockEntity.FIRST_PAGE))
.symbol().orElseThrow(), missing, "Reload must preserve the removed data-pack symbol ID");
helper.assertFalse(restored.canAssemble(), "Missing symbol cannot create a new written Age");
helper.assertTrue(restored.getItem(BookBinderBlockEntity.FIRST_PAGE).is(ModItems.PAGE.get()),
"Rejected writing must remain recoverable as a Page");
helper.succeed();
}
@GameTest(template = "empty") @GameTest(template = "empty")
public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) { public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper); BookBinderBlockEntity binder = place(helper);
@@ -99,6 +118,8 @@ public final class BookBinderGameTests {
helper.assertTrue(age != null, "Assembled book must already be a written Age book"); helper.assertTrue(age != null, "Assembled book must already be a written Age book");
OrderedWriting writing = book.get(ModDataComponents.BOOK_WRITING.get()); OrderedWriting writing = book.get(ModDataComponents.BOOK_WRITING.get());
helper.assertTrue(writing != null, "Assembled book must keep its writing"); helper.assertTrue(writing != null, "Assembled book must keep its writing");
helper.assertValueEqual(writing.schemaVersion(), OrderedWriting.CURRENT_SCHEMA_VERSION,
"Assembled writing must use the current schema");
helper.assertValueEqual(writing.entries().size(), 3, "Link Panel and two pages must be present"); helper.assertValueEqual(writing.entries().size(), 3, "Link Panel and two pages must be present");
helper.assertValueEqual(writing.entries().get(0).kind(), WritingEntry.Kind.LINK_PANEL, helper.assertValueEqual(writing.entries().get(0).kind(), WritingEntry.Kind.LINK_PANEL,
"Link Panel must be first"); "Link Panel must be first");
@@ -108,6 +129,15 @@ public final class BookBinderGameTests {
"rivencraft:weather/normal", "Weather page order must be preserved"); "rivencraft:weather/normal", "Weather page order must be preserved");
helper.assertTrue(AgeRegistrySavedData.get(helper.getLevel()).find(age.id()).isPresent(), helper.assertTrue(AgeRegistrySavedData.get(helper.getLevel()).find(age.id()).isPresent(),
"Taking the completed book must register its random Age definition"); "Taking the completed book must register its random Age definition");
ItemStack copy = book.copy();
helper.assertValueEqual(copy.get(ModDataComponents.BOOK_WRITING.get()), writing,
"A book copy must retain identical ordered writing");
helper.assertValueEqual(copy.get(ModDataComponents.AGE_REFERENCE.get()), age,
"A book copy must retain the same Age rather than assigning a new one");
var savedBook = (net.minecraft.nbt.CompoundTag) copy.saveOptional(helper.getLevel().registryAccess());
ItemStack restoredBook = ItemStack.parseOptional(helper.getLevel().registryAccess(), savedBook);
helper.assertValueEqual(restoredBook.get(ModDataComponents.BOOK_WRITING.get()), writing,
"Versioned ordered writing must survive full book save/load");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).isEmpty(), "Cover must be consumed once"); helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).isEmpty(), "Cover must be consumed once");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty(), "Link Panel must be consumed once"); helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty(), "Link Panel must be consumed once");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE).isEmpty(), "Pages must be consumed once"); helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE).isEmpty(), "Pages must be consumed once");
@@ -226,6 +256,39 @@ public final class BookBinderGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void forgedBinderActionsAndReplayedOutputCannotDuplicateBook(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setTitle("Single Assembly");
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
var first = helper.makeMockServerPlayerInLevel();
var second = helper.makeMockServerPlayerInLevel();
for (var player : List.of(first, second)) player.moveTo(binder.getBlockPos().getX() + 0.5,
binder.getBlockPos().getY() + 0.5, binder.getBlockPos().getZ() + 0.5);
BookBinderMenu firstMenu = new BookBinderMenu(1, first.getInventory(), binder);
BookBinderMenu secondMenu = new BookBinderMenu(2, second.getInventory(), binder);
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.SELECT_BASE + BookBinderBlockEntity.PAGE_COUNT),
"A fabricated page selection must be rejected");
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.STRIP_CLICK_BASE
+ BookBinderBlockEntity.PAGE_COUNT + 1), "A fabricated strip address must be rejected");
helper.assertTrue(firstMenu.quickMoveStack(first, BookBinderMenu.RESULT).is(ModItems.DESCRIPTIVE_BOOK.get()),
"The first viewer must receive the single assembled book");
helper.assertTrue(secondMenu.quickMoveStack(second, BookBinderMenu.RESULT).isEmpty(),
"Another viewer must not take a stale result");
helper.assertTrue(firstMenu.quickMoveStack(first, BookBinderMenu.RESULT).isEmpty(),
"Replaying the output request must not create a second book");
int books = first.getInventory().items.stream().filter(stack -> stack.is(ModItems.DESCRIPTIVE_BOOK.get()))
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(books, 1, "Only one completed book may be emitted");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).isEmpty(), "Cover must be consumed once");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty(), "Panel must be consumed once");
helper.getLevel().setBlock(binder.getBlockPos(), Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.IMPORT_NOTEBOOK),
"A stale binder menu must reject packets after block removal");
helper.succeed();
}
@GameTest(template = "empty") @GameTest(template = "empty")
public static void legacyStripInsertsAndRemovesPagesInOrder(GameTestHelper helper) { public static void legacyStripInsertsAndRemovesPagesInOrder(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper); BookBinderBlockEntity binder = place(helper);
@@ -0,0 +1,113 @@
package net.dnorberg.rivencraft.gametest;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class InkMixerGameTests {
private InkMixerGameTests() {}
private static InkMixerBlockEntity place(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.INK_MIXER.get().defaultBlockState(), 3);
return (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos);
}
@GameTest(template = "empty")
public static void linkPanelRequiresTakingOutputAndPersistsInk(GameTestHelper helper) {
InkMixerBlockEntity mixer = place(helper);
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(mixer.getBlockPos().getX() + 0.5, mixer.getBlockPos().getY() + 0.5,
mixer.getBlockPos().getZ() + 0.5);
InkMixerMenu menu = new InkMixerMenu(1, player.getInventory(), mixer);
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(InkMixerMenu.RESULT).getItem().is(ModItems.PAGE.get()),
"Paper and filled basin must preview a Link Panel");
helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 2,
"Preview must not consume paper");
helper.assertValueEqual(mixer.inkRemaining(), 16, "Vial must fill the basin");
helper.assertTrue(mixer.getItem(InkMixerBlockEntity.BOTTLES).is(Items.GLASS_BOTTLE),
"Consumed vial must return a vanilla glass bottle");
menu.clicked(InkMixerMenu.RESULT, 0, ClickType.PICKUP, player);
helper.assertTrue(menu.getCarried().is(ModItems.PAGE.get()), "Taking result must give a Link Panel");
helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 1,
"Taking result must use exactly one paper");
helper.assertValueEqual(mixer.inkRemaining(), 16 - WritingInkService.linkPanelCost(),
"Taking result must spend the configured ink cost");
var saved = mixer.saveWithoutMetadata(helper.getLevel().registryAccess());
var restored = new InkMixerBlockEntity(mixer.getBlockPos(), ModBlocks.INK_MIXER.get().defaultBlockState());
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
helper.assertValueEqual(restored.inkRemaining(), mixer.inkRemaining(), "Ink basin must persist");
helper.assertValueEqual(restored.getItem(InkMixerBlockEntity.PAPER).getCount(), 1,
"Paper input must persist");
helper.succeed();
}
@GameTest(template = "empty")
public static void bottleCapacityBlocksAndThenPermitsRefill(GameTestHelper helper) {
InkMixerBlockEntity mixer = place(helper);
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER));
mixer.setItem(InkMixerBlockEntity.BOTTLES, new ItemStack(Items.GLASS_BOTTLE, 64));
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
helper.assertValueEqual(mixer.inkRemaining(), 0, "Full bottle output must block refill");
helper.assertTrue(mixer.getItem(InkMixerBlockEntity.INK).is(ModItems.INK_VIAL.get()),
"Blocked refill must preserve vial");
helper.assertFalse(mixer.canCreateLinkPanel(), "Blocked refill must suppress output");
mixer.removeItem(InkMixerBlockEntity.BOTTLES, 1);
helper.assertValueEqual(mixer.inkRemaining(), 16, "Making bottle space must immediately refill");
helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.BOTTLES).getCount(), 64,
"One bottle must be returned after refill");
helper.assertTrue(mixer.canCreateLinkPanel(), "Refill must enable Link Panel output");
helper.succeed();
}
@GameTest(template = "empty")
public static void shiftClickNeedsInventorySpaceAndBreakingReturnsInputs(GameTestHelper helper) {
InkMixerBlockEntity mixer = place(helper);
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(mixer.getBlockPos().getX() + 0.5, mixer.getBlockPos().getY() + 0.5,
mixer.getBlockPos().getZ() + 0.5);
InkMixerMenu menu = new InkMixerMenu(1, player.getInventory(), mixer);
for (int slot = 0; slot < 36; slot++) player.getInventory().setItem(slot, new ItemStack(Items.STONE, 64));
helper.assertTrue(menu.quickMoveStack(player, InkMixerMenu.RESULT).isEmpty(),
"Full inventory must not consume mixer output");
helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 2,
"Full inventory must preserve paper");
player.getInventory().setItem(0, ItemStack.EMPTY);
helper.assertTrue(menu.quickMoveStack(player, InkMixerMenu.RESULT).is(ModItems.PAGE.get()),
"Free inventory space must accept the Link Panel");
helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 1,
"Shift-click must consume only one paper");
helper.getLevel().setBlock(mixer.getBlockPos(), Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(menu.stillValid(player), "Breaking the mixer must invalidate its menu");
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class,
new AABB(mixer.getBlockPos()).inflate(2));
int paper = drops.stream().filter(entity -> entity.getItem().is(Items.PAPER))
.mapToInt(entity -> entity.getItem().getCount()).sum();
int bottles = drops.stream().filter(entity -> entity.getItem().is(Items.GLASS_BOTTLE))
.mapToInt(entity -> entity.getItem().getCount()).sum();
helper.assertValueEqual(paper, 1, "Breaking the mixer must return unused paper once");
helper.assertValueEqual(bottles, 1, "Breaking the mixer must return the consumed vial's bottle once");
helper.succeed();
}
}
@@ -0,0 +1,66 @@
package net.dnorberg.rivencraft.gametest;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
import net.minecraft.core.component.DataComponents;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.crafting.CraftingBookCategory;
import net.minecraft.world.item.crafting.CraftingInput;
import net.minecraft.world.item.crafting.RecipeType;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class LinkingBookRecipeGameTests {
private LinkingBookRecipeGameTests() {}
@GameTest(template = "empty")
public static void legacyRecipeRequiresExactlyLeatherAndLinkPanel(GameTestHelper helper) {
LinkingBookRecipe recipe = new LinkingBookRecipe(CraftingBookCategory.MISC);
ItemStack panel = PageItem.createLinkPanelPage();
CraftingInput valid = CraftingInput.of(2, 1, List.of(panel, new ItemStack(Items.LEATHER)));
helper.assertTrue(recipe.matches(valid, helper.getLevel()), "Link Panel and leather must match");
helper.assertTrue(recipe.matches(CraftingInput.of(2, 1,
List.of(new ItemStack(Items.LEATHER), panel)), helper.getLevel()),
"The Legacy recipe must be shapeless");
helper.assertFalse(recipe.matches(CraftingInput.of(2, 1,
List.of(new ItemStack(Items.BOOK), new ItemStack(Items.ENDER_PEARL))), helper.getLevel()),
"The old placeholder recipe must no longer match");
helper.assertFalse(recipe.matches(CraftingInput.of(2, 1,
List.of(new ItemStack(ModItems.PAGE.get()), new ItemStack(Items.LEATHER))), helper.getLevel()),
"A blank Page must not substitute for a Link Panel");
helper.assertFalse(recipe.matches(CraftingInput.of(3, 1,
List.of(panel, new ItemStack(Items.LEATHER), new ItemStack(Items.PAPER))), helper.getLevel()),
"An extra ingredient must invalidate the recipe");
helper.succeed();
}
@GameTest(template = "empty")
public static void outputIsUnwrittenAndRegisteredRecipeLoads(GameTestHelper helper) {
ItemStack panel = PageItem.createLinkPanelPage();
panel.set(DataComponents.CUSTOM_NAME, Component.literal("Home Panel"));
CraftingInput input = CraftingInput.of(2, 1, List.of(panel, new ItemStack(Items.LEATHER)));
var holder = helper.getLevel().getRecipeManager()
.getRecipeFor(RecipeType.CRAFTING, input, helper.getLevel()).orElseThrow();
helper.assertValueEqual(holder.id(), ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "linking_book"),
"The registered recipe must replace the old placeholder");
ItemStack result = holder.value().assemble(input, helper.getLevel().registryAccess());
helper.assertTrue(result.is(ModItems.LINKING_BOOK.get()), "Crafting must produce a Linking Book");
helper.assertValueEqual(result.getCount(), 1, "Crafting must produce one book");
helper.assertFalse(result.has(ModDataComponents.LINK_TARGET.get()), "The book must be unwritten/unbound");
helper.assertFalse(result.has(ModDataComponents.PAGE_CONTENT.get()), "Page identity must not leak onto the book");
helper.assertValueEqual(result.get(DataComponents.CUSTOM_NAME), Component.literal("Home Panel"),
"The panel's custom name must survive crafting");
helper.succeed();
}
}
@@ -3,7 +3,9 @@ package net.dnorberg.rivencraft.gametest;
import com.mojang.serialization.JsonOps; import com.mojang.serialization.JsonOps;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import java.util.List; import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog; import net.dnorberg.rivencraft.writing.SymbolCatalog;
@@ -46,6 +48,8 @@ public final class WritingDataGameTests {
)); ));
var json = OrderedWriting.CODEC.encodeStart(JsonOps.INSTANCE, original).result().orElseThrow(); var json = OrderedWriting.CODEC.encodeStart(JsonOps.INSTANCE, original).result().orElseThrow();
helper.assertValueEqual(json.getAsJsonObject().get("schema_version").getAsInt(),
OrderedWriting.CURRENT_SCHEMA_VERSION, "Writing must save an explicit schema version");
OrderedWriting persistentCopy = OrderedWriting.CODEC.parse(JsonOps.INSTANCE, json).result().orElseThrow(); OrderedWriting persistentCopy = OrderedWriting.CODEC.parse(JsonOps.INSTANCE, json).result().orElseThrow();
helper.assertValueEqual(persistentCopy, original, helper.assertValueEqual(persistentCopy, original,
"Persistent writing codec must retain entry order and unknown symbol IDs"); "Persistent writing codec must retain entry order and unknown symbol IDs");
@@ -58,6 +62,28 @@ public final class WritingDataGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void oldBareListWritingAndPageFreeBooksRemainReadable(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(
WritingEntry.linkPanel(), WritingEntry.symbol(reference("example:removed_symbol"))));
var oldJson = WritingEntry.CODEC.listOf().encodeStart(JsonOps.INSTANCE, writing.entries())
.result().orElseThrow();
OrderedWriting migrated = OrderedWriting.CODEC.parse(JsonOps.INSTANCE, oldJson).result().orElseThrow();
helper.assertValueEqual(migrated, writing,
"0.3.7.2 bare-list writing must load into schema version 1 intact");
ItemStack oldPageFreeBook = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
AgeReference originalAge = new AgeReference(UUID.randomUUID(), "Legacy Page-Free Age");
oldPageFreeBook.set(ModDataComponents.AGE_REFERENCE.get(), originalAge);
CompoundTag saved = (CompoundTag) oldPageFreeBook.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), saved);
helper.assertValueEqual(restored.get(ModDataComponents.AGE_REFERENCE.get()), originalAge,
"A page-free book must keep its existing Age identity");
helper.assertValueEqual(restored.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY),
OrderedWriting.EMPTY, "Older page-free books must not require writing migration");
helper.succeed();
}
@GameTest(template = "empty") @GameTest(template = "empty")
public static void serverCatalogResolvesBuiltinsAndKeepsUnknownReferencesReadable(GameTestHelper helper) { public static void serverCatalogResolvesBuiltinsAndKeepsUnknownReferencesReadable(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard"); SymbolReference known = reference("rivencraft:terrain/standard");
@@ -116,6 +142,10 @@ public final class WritingDataGameTests {
); );
ItemStack recovered = PageItem.createRecoveredPage(unknown); ItemStack recovered = PageItem.createRecoveredPage(unknown);
CompoundTag recoveredTag = (CompoundTag) recovered.saveOptional(helper.getLevel().registryAccess());
ItemStack restoredRecovered = ItemStack.parseOptional(helper.getLevel().registryAccess(), recoveredTag);
helper.assertValueEqual(PageItem.content(restoredRecovered).symbol().orElseThrow(), unknown,
"A missing symbol must survive a full Page item reload");
helper.assertFalse(PageItem.isUsableWriting(recovered), helper.assertFalse(PageItem.isUsableWriting(recovered),
"An unresolved page must remain recoverable without becoming valid writing"); "An unresolved page must remain recoverable without becoming valid writing");
try { try {
@@ -249,11 +249,40 @@ public final class WritingDeskGameTests {
"First viewer should take the last available copy"); "First viewer should take the last available copy");
helper.assertTrue(secondMenu.quickMoveStack(second, WritingDeskMenu.COPY_RESULT).isEmpty(), helper.assertTrue(secondMenu.quickMoveStack(second, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Second viewer must not copy the already-consumed paper"); "Second viewer must not copy the already-consumed paper");
helper.assertTrue(firstMenu.quickMoveStack(first, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Replaying the first viewer's output request must not make another page");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(), helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
"Shared desk must spend paper only once"); "Shared desk must spend paper only once");
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void forgedDeskSelectionsAndStaleMenuCannotCopy(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.CATALOG_BUTTON_BASE + 999),
"A fabricated catalog index must be rejected");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + 27),
"A fabricated page address must be rejected");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 0,
WritingDeskBlockEntity.PAPER)), "A page cannot be extracted into a material slot");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Forged actions must not spend paper");
helper.assertTrue(desk.getItem(0).is(ModItems.PAGE.get()), "Forged actions must not remove the source");
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(menu.clickMenuButton(player, 0), "A stale menu must reject selection packets");
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
"A stale menu must reject output packets");
helper.succeed();
}
@GameTest(template = "empty") @GameTest(template = "empty")
public static void catalogAggregatesKnownSymbolsAcrossCollections(GameTestHelper helper) { public static void catalogAggregatesKnownSymbolsAcrossCollections(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.item; package net.dnorberg.rivencraft.item;
import java.util.List; import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.PageContent;
@@ -28,6 +29,23 @@ public final class PageItem extends Item {
return create(PageContent.symbol(reference)); return create(PageContent.symbol(reference));
} }
/** Fixed starter examples for the creative tab; the server still validates catalog membership on use. */
public static ItemStack createCreativeStarterPage(String path) {
if (!List.of("terrain/standard", "biome/native", "lighting/normal", "weather/normal",
"celestial/normal_time", "feature/caves").contains(path)) {
throw new IllegalArgumentException("Not a starter symbol: " + path);
}
SymbolReference reference = new SymbolReference(
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path));
ItemStack page = create(PageContent.symbol(reference));
String key = "symbol.rivencraft." + path.replace('/', '.');
page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), new SymbolDisplayMetadata(
reference.id(), key, key + ".description",
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path.split("/")[0]),
true));
return page;
}
public static ItemStack createLinkPanelPage() { public static ItemStack createLinkPanelPage() {
return create(PageContent.LINK_PANEL); return create(PageContent.LINK_PANEL);
} }
@@ -0,0 +1,119 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.DataSlot;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Server-owned virtual output; taking it spends paper and the configured ink cost once. */
public final class InkMixerMenu extends AbstractContainerMenu {
public static final int RESULT = InkMixerBlockEntity.SIZE;
private static final int PLAYER_START = RESULT + 1;
private final InkMixerBlockEntity mixer;
private final Container inputs;
private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide;
private int inkRemaining;
public InkMixerMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof InkMixerBlockEntity found ? found : null);
}
public InkMixerMenu(int id, Inventory inventory, InkMixerBlockEntity mixer) {
super(ModMenus.INK_MIXER.get(), id);
this.mixer = mixer;
inputs = mixer == null ? new SimpleContainer(InkMixerBlockEntity.SIZE) : mixer;
clientSide = inventory.player.level().isClientSide;
addDataSlot(new DataSlot() {
@Override public int get() { return clientSide ? inkRemaining : mixer.inkRemaining(); }
@Override public void set(int value) { inkRemaining = value; }
});
addSlot(new MixerSlot(InkMixerBlockEntity.INK, 8, 27));
addSlot(new MixerSlot(InkMixerBlockEntity.PAPER, 8, 48));
addSlot(new MixerSlot(InkMixerBlockEntity.BOTTLES, 152, 27));
addSlot(new ResultSlot(152, 48));
for (int row = 0; row < 3; row++)
for (int column = 0; column < 9; column++)
addSlot(new Slot(inventory, column + row * 9 + 9, 8 + column * 18, 99 + row * 18));
for (int column = 0; column < 9; column++)
addSlot(new Slot(inventory, column, 8 + column * 18, 157));
refreshResult();
}
public int inkRemaining() { return inkRemaining; }
@Override public boolean stillValid(Player player) {
return mixer != null && mixer.stillValid(player);
}
@Override public void broadcastChanges() {
refreshResult();
super.broadcastChanges();
}
private void refreshResult() {
if (clientSide) return;
ItemStack preview = mixer != null && mixer.canCreateLinkPanel()
? PageItem.createLinkPanelPage() : ItemStack.EMPTY;
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
if (index == RESULT) {
refreshResult();
if (!mixer.canCreateLinkPanel()) return ItemStack.EMPTY;
ItemStack output = result.getItem(0).copy();
ItemStack remainder = output.copy();
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
return ItemStack.EMPTY;
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
refreshResult();
return output;
}
Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack source = slot.getItem();
ItemStack original = source.copy();
boolean moved;
if (index < RESULT) moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
else if (source.is(ModItems.INK_VIAL.get()))
moved = moveItemStackTo(source, InkMixerBlockEntity.INK, InkMixerBlockEntity.INK + 1, false);
else if (source.is(Items.PAPER))
moved = moveItemStackTo(source, InkMixerBlockEntity.PAPER, InkMixerBlockEntity.PAPER + 1, false);
else moved = false;
if (!moved) return ItemStack.EMPTY;
if (source.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
else slot.setChanged();
refreshResult();
return original;
}
private final class MixerSlot extends Slot {
MixerSlot(int index, int x, int y) { super(inputs, index, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return inputs.canPlaceItem(getContainerSlot(), stack); }
}
private final class ResultSlot extends Slot {
ResultSlot(int x, int y) { super(result, 0, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) {
return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && mixer.consumeLinkPanelMaterials()) refreshResult();
super.onTake(player, stack);
}
}
}
@@ -16,6 +16,10 @@ import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.menu.BookBinderMenu; import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.minecraft.core.component.DataComponents; import net.minecraft.core.component.DataComponents;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos; import net.minecraft.core.GlobalPos;
@@ -50,7 +54,7 @@ public final class ModNetworking {
} }
public static void register(RegisterPayloadHandlersEvent event) { public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("12"); PayloadRegistrar registrar = event.registrar("13");
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC, registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
ModNetworking::requestDescriptiveBookScreen); ModNetworking::requestDescriptiveBookScreen);
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC, registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
@@ -103,10 +107,15 @@ public final class ModNetworking {
// Requests run on the same server thread as Age creation. A request queued // Requests run on the same server thread as Age creation. A request queued
// during generation can only open the finished book, never a stale client copy. // during generation can only open the finished book, never a stale client copy.
player.inventoryMenu.broadcastChanges(); player.inventoryMenu.broadcastChanges();
List<SymbolDisplayMetadata> writingSymbols = access.stack()
.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY).entries().stream()
.filter(entry -> entry.kind() == WritingEntry.Kind.SYMBOL)
.map(entry -> SymbolCatalog.display(entry.symbol().orElseThrow()))
.toList();
PacketDistributor.sendToPlayer(player, new OpenDescriptiveBookScreenPayload( PacketDistributor.sendToPlayer(player, new OpenDescriptiveBookScreenPayload(
payload.fromStand(), payload.offhand(), payload.fromStand(), payload.offhand(),
GlobalPos.of(player.level().dimension(), payload.standPos()), access.stack().copy(), GlobalPos.of(player.level().dimension(), payload.standPos()), access.stack().copy(),
definition == null ? null : definition.traits())); definition == null ? null : definition.traits(), writingSymbols));
} }
private static void bindDescriptiveBook(BindDescriptiveBookPayload payload, IPayloadContext context) { private static void bindDescriptiveBook(BindDescriptiveBookPayload payload, IPayloadContext context) {
@@ -1,7 +1,11 @@
package net.dnorberg.rivencraft.network; package net.dnorberg.rivencraft.network;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeTraits; import net.dnorberg.rivencraft.book.AgeTraits;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.core.GlobalPos; import net.minecraft.core.GlobalPos;
import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.codec.StreamCodec;
@@ -16,7 +20,8 @@ public record OpenDescriptiveBookScreenPayload(
boolean offhand, boolean offhand,
GlobalPos source, GlobalPos source,
ItemStack book, ItemStack book,
@Nullable AgeTraits traits @Nullable AgeTraits traits,
List<SymbolDisplayMetadata> writingSymbols
) )
implements CustomPacketPayload { implements CustomPacketPayload {
public static final Type<OpenDescriptiveBookScreenPayload> TYPE = new Type<>( public static final Type<OpenDescriptiveBookScreenPayload> TYPE = new Type<>(
@@ -40,6 +45,11 @@ public record OpenDescriptiveBookScreenPayload(
buffer.writeFloat(traits.celestialAngle()); buffer.writeFloat(traits.celestialAngle());
buffer.writeFloat(traits.celestialPhase()); buffer.writeFloat(traits.celestialPhase());
} }
if (payload.writingSymbols().size() > OrderedWriting.MAX_ENTRIES) {
throw new IllegalArgumentException("Too many book symbol displays");
}
buffer.writeVarInt(payload.writingSymbols().size());
payload.writingSymbols().forEach(symbol -> SymbolDisplayMetadata.STREAM_CODEC.encode(buffer, symbol));
} }
private static OpenDescriptiveBookScreenPayload decode(RegistryFriendlyByteBuf buffer) { private static OpenDescriptiveBookScreenPayload decode(RegistryFriendlyByteBuf buffer) {
@@ -57,7 +67,16 @@ public record OpenDescriptiveBookScreenPayload(
buffer.readFloat() buffer.readFloat()
) )
: null; : null;
return new OpenDescriptiveBookScreenPayload(fromStand, offhand, source, book, traits); int count = buffer.readVarInt();
if (count < 0 || count > OrderedWriting.MAX_ENTRIES) {
throw new IllegalArgumentException("Invalid book symbol display count " + count);
}
List<SymbolDisplayMetadata> writingSymbols = new ArrayList<>(count);
for (int index = 0; index < count; index++) {
writingSymbols.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer));
}
return new OpenDescriptiveBookScreenPayload(fromStand, offhand, source, book, traits,
List.copyOf(writingSymbols));
} }
@Override @Override
@@ -4,6 +4,7 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity; import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity; import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.entity.BlockEntityType;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
@@ -24,6 +25,9 @@ public final class ModBlockEntities {
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<BookBinderBlockEntity>> BOOK_BINDER = public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<BookBinderBlockEntity>> BOOK_BINDER =
BLOCK_ENTITIES.register("book_binder", () -> BlockEntityType.Builder BLOCK_ENTITIES.register("book_binder", () -> BlockEntityType.Builder
.of(BookBinderBlockEntity::new, ModBlocks.BOOK_BINDER.get()).build(null)); .of(BookBinderBlockEntity::new, ModBlocks.BOOK_BINDER.get()).build(null));
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<InkMixerBlockEntity>> INK_MIXER =
BLOCK_ENTITIES.register("ink_mixer", () -> BlockEntityType.Builder
.of(InkMixerBlockEntity::new, ModBlocks.INK_MIXER.get()).build(null));
private ModBlockEntities() { private ModBlockEntities() {
} }
@@ -4,6 +4,7 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookStandBlock; import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.BookBinderBlock; import net.dnorberg.rivencraft.block.BookBinderBlock;
import net.dnorberg.rivencraft.block.WritingDeskBlock; import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.InkMixerBlock;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockBehaviour; import net.minecraft.world.level.block.state.BlockBehaviour;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
@@ -26,6 +27,10 @@ public final class ModBlocks {
"book_binder", BookBinderBlock::new, "book_binder", BookBinderBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion() BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
); );
public static final DeferredBlock<InkMixerBlock> INK_MIXER = BLOCKS.registerBlock(
"ink_mixer", InkMixerBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
);
private ModBlocks() { private ModBlocks() {
} }
@@ -23,6 +23,7 @@ public final class ModCreativeTabs {
output.accept(ModItems.NOTEBOOK.get()); output.accept(ModItems.NOTEBOOK.get());
output.accept(ModItems.INK_VIAL.get()); output.accept(ModItems.INK_VIAL.get());
output.accept(ModItems.WRITING_DESK.get()); output.accept(ModItems.WRITING_DESK.get());
output.accept(ModItems.INK_MIXER.get());
output.accept(ModItems.BOOK_BINDER.get()); output.accept(ModItems.BOOK_BINDER.get());
}) })
.build()); .build());
@@ -32,7 +33,12 @@ public final class ModCreativeTabs {
.title(Component.translatable("itemGroup.rivencraft.pages")) .title(Component.translatable("itemGroup.rivencraft.pages"))
.icon(PageItem::createLinkPanelPage) .icon(PageItem::createLinkPanelPage)
.displayItems((parameters, output) -> { .displayItems((parameters, output) -> {
output.accept(ModItems.PAGE.get());
output.accept(PageItem.createLinkPanelPage()); output.accept(PageItem.createLinkPanelPage());
for (String id : new String[] {"terrain/standard", "biome/native", "lighting/normal",
"weather/normal", "celestial/normal_time", "feature/caves"}) {
output.accept(PageItem.createCreativeStarterPage(id));
}
}) })
.build()); .build());
@@ -42,6 +42,7 @@ public final class ModItems {
); );
public static final DeferredItem<BlockItem> WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK); public static final DeferredItem<BlockItem> WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK);
public static final DeferredItem<BlockItem> BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER); public static final DeferredItem<BlockItem> BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER);
public static final DeferredItem<BlockItem> INK_MIXER = ITEMS.registerSimpleBlockItem(ModBlocks.INK_MIXER);
private ModItems() { private ModItems() {
} }
@@ -4,6 +4,7 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.menu.NotebookMenu; import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.menu.WritingDeskMenu; import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.menu.BookBinderMenu; import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import net.minecraft.world.inventory.MenuType; import net.minecraft.world.inventory.MenuType;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
@@ -21,6 +22,8 @@ public final class ModMenus {
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(WritingDeskMenu::new)); MENUS.register("writing_desk", () -> IMenuTypeExtension.create(WritingDeskMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<BookBinderMenu>> BOOK_BINDER = public static final DeferredHolder<MenuType<?>, MenuType<BookBinderMenu>> BOOK_BINDER =
MENUS.register("book_binder", () -> IMenuTypeExtension.create(BookBinderMenu::new)); MENUS.register("book_binder", () -> IMenuTypeExtension.create(BookBinderMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<InkMixerMenu>> INK_MIXER =
MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new));
private ModMenus() { private ModMenus() {
} }
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.crafting.SimpleCraftingRecipeSerializer;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
public final class ModRecipeSerializers {
private static final DeferredRegister<RecipeSerializer<?>> SERIALIZERS =
DeferredRegister.create(Registries.RECIPE_SERIALIZER, Rivencraft.MOD_ID);
public static final DeferredHolder<RecipeSerializer<?>, RecipeSerializer<LinkingBookRecipe>> LINKING_BOOK =
SERIALIZERS.register("linking_book", () -> new SimpleCraftingRecipeSerializer<>(LinkingBookRecipe::new));
private ModRecipeSerializers() {}
public static void register(IEventBus modEventBus) {
SERIALIZERS.register(modEventBus);
}
}
@@ -0,0 +1,80 @@
package net.dnorberg.rivencraft.writing;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModRecipeSerializers;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.crafting.CraftingBookCategory;
import net.minecraft.world.item.crafting.CraftingInput;
import net.minecraft.world.item.crafting.CustomRecipe;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.level.Level;
import net.neoforged.neoforge.common.crafting.DataComponentIngredient;
/** Legacy's shapeless leather + Link Panel recipe, adapted to page components. */
public final class LinkingBookRecipe extends CustomRecipe {
public LinkingBookRecipe(CraftingBookCategory category) {
super(category);
}
private static ItemStack findPanel(CraftingInput input) {
if (input.ingredientCount() != 2) return ItemStack.EMPTY;
ItemStack panel = ItemStack.EMPTY;
boolean leather = false;
for (ItemStack stack : input.items()) {
if (stack.isEmpty()) continue;
if (stack.is(ModItems.PAGE.get()) && PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL
&& panel.isEmpty()) {
panel = stack;
} else if (stack.is(Items.LEATHER) && !leather) {
leather = true;
} else {
return ItemStack.EMPTY;
}
}
return leather ? panel : ItemStack.EMPTY;
}
@Override
public boolean matches(CraftingInput input, Level level) {
return !findPanel(input).isEmpty();
}
@Override
public ItemStack assemble(CraftingInput input, HolderLookup.Provider registries) {
ItemStack panel = findPanel(input);
if (panel.isEmpty()) return ItemStack.EMPTY;
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
// Legacy copied the panel's data. Only its custom name is meaningful to a
// 1.21.1 Linking Book today; page-only components must not reach the book.
if (panel.has(DataComponents.CUSTOM_NAME)) {
book.set(DataComponents.CUSTOM_NAME, panel.get(DataComponents.CUSTOM_NAME));
}
return book;
}
@Override
public boolean canCraftInDimensions(int width, int height) {
return width * height >= 2;
}
@Override
public NonNullList<Ingredient> getIngredients() {
NonNullList<Ingredient> ingredients = NonNullList.create();
ingredients.add(DataComponentIngredient.of(false,
net.dnorberg.rivencraft.registry.ModDataComponents.PAGE_CONTENT.get(),
PageContent.LINK_PANEL, ModItems.PAGE.get()));
ingredients.add(Ingredient.of(Items.LEATHER));
return ingredients;
}
@Override
public RecipeSerializer<?> getSerializer() {
return ModRecipeSerializers.LINKING_BOOK.get();
}
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec; import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult; import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -10,30 +11,50 @@ import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.codec.StreamCodec;
/** Immutable ordered writing shared by pages, notebooks, desks, and books. */ /** Immutable ordered writing shared by pages, notebooks, desks, and books. */
public record OrderedWriting(List<WritingEntry> entries) { public record OrderedWriting(int schemaVersion, List<WritingEntry> entries) {
public static final int CURRENT_SCHEMA_VERSION = 1;
public static final int MAX_ENTRIES = 256; public static final int MAX_ENTRIES = 256;
public static final OrderedWriting EMPTY = new OrderedWriting(List.of()); public static final OrderedWriting EMPTY = new OrderedWriting(List.of());
public static final Codec<OrderedWriting> CODEC = WritingEntry.CODEC.listOf() private static final Codec<List<WritingEntry>> ENTRIES_CODEC = WritingEntry.CODEC.listOf()
.validate(entries -> entries.size() <= MAX_ENTRIES .validate(entries -> entries.size() <= MAX_ENTRIES
? DataResult.success(entries) ? DataResult.success(entries)
: DataResult.error(() -> "Writing exceeds " + MAX_ENTRIES + " entries")) : DataResult.error(() -> "Writing exceeds " + MAX_ENTRIES + " entries"));
.xmap(OrderedWriting::new, OrderedWriting::entries); private static final Codec<OrderedWriting> VERSIONED_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.intRange(CURRENT_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION)
.fieldOf("schema_version").forGetter(OrderedWriting::schemaVersion),
ENTRIES_CODEC.fieldOf("entries").forGetter(OrderedWriting::entries)
).apply(instance, OrderedWriting::new));
// 0.3.7.2 stored BOOK_WRITING as a bare list. Decode it without changing its order.
public static final Codec<OrderedWriting> CODEC = Codec.withAlternative(
VERSIONED_CODEC, ENTRIES_CODEC.xmap(OrderedWriting::new, OrderedWriting::entries));
public static final StreamCodec<ByteBuf, OrderedWriting> STREAM_CODEC = StreamCodec.of( public static final StreamCodec<ByteBuf, OrderedWriting> STREAM_CODEC = StreamCodec.of(
OrderedWriting::encode, OrderedWriting::decode); OrderedWriting::encode, OrderedWriting::decode);
public OrderedWriting { public OrderedWriting {
if (schemaVersion != CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported writing schema version " + schemaVersion);
}
entries = List.copyOf(Objects.requireNonNull(entries, "entries")); entries = List.copyOf(Objects.requireNonNull(entries, "entries"));
if (entries.size() > MAX_ENTRIES) { if (entries.size() > MAX_ENTRIES) {
throw new IllegalArgumentException("Writing exceeds " + MAX_ENTRIES + " entries"); throw new IllegalArgumentException("Writing exceeds " + MAX_ENTRIES + " entries");
} }
} }
public OrderedWriting(List<WritingEntry> entries) {
this(CURRENT_SCHEMA_VERSION, entries);
}
private static void encode(ByteBuf buffer, OrderedWriting writing) { private static void encode(ByteBuf buffer, OrderedWriting writing) {
ByteBufCodecs.VAR_INT.encode(buffer, writing.schemaVersion);
ByteBufCodecs.VAR_INT.encode(buffer, writing.entries.size()); ByteBufCodecs.VAR_INT.encode(buffer, writing.entries.size());
writing.entries.forEach(entry -> WritingEntry.STREAM_CODEC.encode(buffer, entry)); writing.entries.forEach(entry -> WritingEntry.STREAM_CODEC.encode(buffer, entry));
} }
private static OrderedWriting decode(ByteBuf buffer) { private static OrderedWriting decode(ByteBuf buffer) {
int schemaVersion = ByteBufCodecs.VAR_INT.decode(buffer);
if (schemaVersion != CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported writing schema version " + schemaVersion);
}
int size = ByteBufCodecs.VAR_INT.decode(buffer); int size = ByteBufCodecs.VAR_INT.decode(buffer);
if (size < 0 || size > MAX_ENTRIES) { if (size < 0 || size > MAX_ENTRIES) {
throw new IllegalArgumentException("Invalid writing entry count " + size); throw new IllegalArgumentException("Invalid writing entry count " + size);
@@ -42,6 +63,6 @@ public record OrderedWriting(List<WritingEntry> entries) {
for (int index = 0; index < size; index++) { for (int index = 0; index < size; index++) {
entries.add(WritingEntry.STREAM_CODEC.decode(buffer)); entries.add(WritingEntry.STREAM_CODEC.decode(buffer));
} }
return new OrderedWriting(entries); return new OrderedWriting(schemaVersion, entries);
} }
} }
@@ -0,0 +1,5 @@
{
"variants": {
"": { "model": "rivencraft:block/ink_mixer" }
}
}
@@ -20,6 +20,9 @@
"container.rivencraft.writing_desk": "Writing Desk", "container.rivencraft.writing_desk": "Writing Desk",
"block.rivencraft.book_binder": "Book Binder", "block.rivencraft.book_binder": "Book Binder",
"container.rivencraft.book_binder": "Book Binder", "container.rivencraft.book_binder": "Book Binder",
"block.rivencraft.ink_mixer": "Ink Mixer",
"container.rivencraft.ink_mixer": "Ink Mixer",
"screen.rivencraft.ink_mixer.ink": "Ink: %s/%s",
"screen.rivencraft.book_binder.title": "Book title", "screen.rivencraft.book_binder.title": "Book title",
"screen.rivencraft.book_binder.title_hint": "Name the book", "screen.rivencraft.book_binder.title_hint": "Name the book",
"screen.rivencraft.book_binder.missing_panel": "Link Panel required as the first page", "screen.rivencraft.book_binder.missing_panel": "Link Panel required as the first page",
@@ -28,9 +31,17 @@
"screen.rivencraft.book_binder.import.tooltip": "Move all Notebook pages into the binder in order", "screen.rivencraft.book_binder.import.tooltip": "Move all Notebook pages into the binder in order",
"screen.rivencraft.book_binder.move_previous": "Move selected page left", "screen.rivencraft.book_binder.move_previous": "Move selected page left",
"screen.rivencraft.book_binder.move_next": "Move selected page right", "screen.rivencraft.book_binder.move_next": "Move selected page right",
"screen.rivencraft.book_binder.previous_pages": "Scroll pages left (Left Arrow)",
"screen.rivencraft.book_binder.next_pages": "Scroll pages right (Right Arrow)",
"screen.rivencraft.book_binder.select_hint": "Right-click page to select", "screen.rivencraft.book_binder.select_hint": "Right-click page to select",
"screen.rivencraft.writing_desk.ink": "Ink: %s/%s", "screen.rivencraft.writing_desk.ink": "Ink: %s/%s",
"screen.rivencraft.writing_desk.search": "Search", "screen.rivencraft.writing_desk.search": "Search",
"screen.rivencraft.writing_desk.sort": "Sort pages by name (A–Z)",
"screen.rivencraft.writing_desk.all": "Show pages from all tabs",
"screen.rivencraft.writing_desk.category": "Cycle page category: %s",
"screen.rivencraft.writing_desk.all_categories": "All categories",
"screen.rivencraft.writing_desk.previous_tabs": "Previous four tabs (Page Up)",
"screen.rivencraft.writing_desk.next_tabs": "Next four tabs (Page Down)",
"screen.rivencraft.writing_desk.no_symbols": "No available symbols", "screen.rivencraft.writing_desk.no_symbols": "No available symbols",
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s", "item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
"item.rivencraft.ink_vial.usage": "Use with paper in the other hand to prepare a Link Panel.", "item.rivencraft.ink_vial.usage": "Use with paper in the other hand to prepare a Link Panel.",
@@ -60,6 +71,10 @@
"screen.rivencraft.age.name_prompt": "Name this Age before writing the Descriptive Book", "screen.rivencraft.age.name_prompt": "Name this Age before writing the Descriptive Book",
"screen.rivencraft.age.create": "Create", "screen.rivencraft.age.create": "Create",
"screen.rivencraft.age.generator": "Terrain: %s", "screen.rivencraft.age.generator": "Terrain: %s",
"screen.rivencraft.age.pages": "Pages",
"screen.rivencraft.age.details": "Details",
"screen.rivencraft.age.pages_count": "Writing %s/%s",
"screen.rivencraft.age.pages_not_applied": "Symbols not applied yet",
"screen.rivencraft.age.trait.biomes": "Biomes: %s", "screen.rivencraft.age.trait.biomes": "Biomes: %s",
"screen.rivencraft.age.trait.lighting": "Lighting: %s", "screen.rivencraft.age.trait.lighting": "Lighting: %s",
"screen.rivencraft.age.trait.weather": "Weather: %s", "screen.rivencraft.age.trait.weather": "Weather: %s",
@@ -110,10 +125,18 @@
"generator.rivencraft.void": "Void", "generator.rivencraft.void": "Void",
"generator.rivencraft.random_vanilla": "Standard", "generator.rivencraft.random_vanilla": "Standard",
"screen.rivencraft.book.preview_pending": "Loading destination...", "screen.rivencraft.book.preview_pending": "Loading destination...",
"screen.rivencraft.book.click_to_link": "Click the panel to link",
"screen.rivencraft.book.explore": "Explore", "screen.rivencraft.book.explore": "Explore",
"screen.rivencraft.book.exploring": "Exploring", "screen.rivencraft.book.exploring": "Exploring",
"screen.rivencraft.book.return_from_explore": "Right-click to return", "screen.rivencraft.book.return_from_explore": "Right-click to return",
"screen.rivencraft.book.previous_page": "Previous page (Left Arrow)",
"screen.rivencraft.book.next_page": "Next page (Right Arrow)",
"jei.rivencraft.writing": "Rivencraft Writing",
"jei.rivencraft.writing.link_panel_hand": "Hold paper and an Ink Vial in opposite hands; use the vial to make a Link Panel.",
"jei.rivencraft.ink_mixer": "Ink Mixer",
"jei.rivencraft.ink_mixer.usage": "Paper + ink makes a Link Panel. One vial fills 16 ink; each panel uses the configured ink cost. The empty vial returns as a glass bottle.",
"jei.rivencraft.writing.copy": "At a Writing Desk, select a stored Page, add paper and ink, then take the copy. The source Page stays.",
"jei.rivencraft.writing.bind": "At a Book Binder, name the Age and add a cover plus Link Panel. Optional symbol Pages follow the panel.",
"jei.rivencraft.writing.linking_book": "Craft one Link Panel Page with leather to make an unwritten Linking Book. Open it to name and bind it.",
"itemGroup.rivencraft": "Rivencraft", "itemGroup.rivencraft": "Rivencraft",
"itemGroup.rivencraft.pages": "Rivencraft Pages" "itemGroup.rivencraft.pages": "Rivencraft Pages"
} }
@@ -0,0 +1,23 @@
{
"parent": "minecraft:block/block",
"textures": {
"particle": "rivencraft:block/ink_mixer_side",
"side": "rivencraft:block/ink_mixer_side",
"top": "rivencraft:block/ink_mixer_top",
"bottom": "rivencraft:block/ink_mixer_bottom"
},
"elements": [
{
"from": [0, 0, 0],
"to": [16, 12, 16],
"faces": {
"north": { "texture": "#side", "uv": [0, 4, 16, 16] },
"east": { "texture": "#side", "uv": [0, 4, 16, 16] },
"south": { "texture": "#side", "uv": [0, 4, 16, 16] },
"west": { "texture": "#side", "uv": [0, 4, 16, 16] },
"up": { "texture": "#top" },
"down": { "texture": "#bottom" }
}
}
]
}
@@ -0,0 +1,3 @@
{
"parent": "rivencraft:block/ink_mixer"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 747 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,4 @@
{
"type": "minecraft:block",
"pools": [{ "rolls": 1, "entries": [{ "type": "minecraft:item", "name": "rivencraft:ink_mixer" }] }]
}
@@ -0,0 +1,11 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"pattern": ["S S", "SVS", "PSP"],
"key": {
"S": { "item": "minecraft:stone" },
"V": { "item": "minecraft:glass_bottle" },
"P": { "tag": "minecraft:planks" }
},
"result": { "id": "rivencraft:ink_mixer", "count": 1 }
}
@@ -1,16 +1,4 @@
{ {
"type": "minecraft:crafting_shapeless", "type": "rivencraft:linking_book",
"category": "misc", "category": "misc"
"ingredients": [
{
"item": "minecraft:book"
},
{
"item": "minecraft:ender_pearl"
}
],
"result": {
"count": 1,
"id": "rivencraft:linking_book"
}
} }