Compare commits

...
6 Commits
Author SHA1 Message Date
dnorberg 114a6ee21e Streamline GameTests and writing workstation logic
Build / build (push) Canceled after 0s
2026-09-29 23:12:35 -04:00
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
dnorberg 85da1f047d Add Legacy-style Book Binder
Build / build (push) Canceled after 0s
2026-09-29 09:06:34 -04:00
80 changed files with 3023 additions and 212 deletions

No files matched your search

+12
View File
@@ -73,6 +73,18 @@ Or, with mise:
All required GameTests should pass before distributing a build.
GameTests use `build/gametest-run/world`, separate from the normal development
world. Before each run, Gradle removes that test world if it exceeds 512 MiB.
Override the limit with `-PgameTestMaxWorldMiB=NUMBER`. A caller-supplied
`-PgameTestRunDir` is never removed automatically.
To run the suite in a fresh temporary directory instead, use:
test_dir=$(mktemp -d /tmp/rivencraft-gametest.XXXXXX)
./gradlew runGameTestServer --offline -PgameTestRunDir="$test_dir"
With mise, prefix the second command with `mise exec --`.
6. Locate and inspect the JAR
-----------------------------
+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.
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.
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
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
+54 -4
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
1.21.1 on NeoForge.
The bootstrap, books/linking, and Ages milestones are complete. Milestone 4 is
building the writing system on a data-pack symbol catalog and persistent
The bootstrap, books/linking, Ages, and writing-system milestones are complete.
The writing system uses a data-pack symbol catalog and persistent
ordered-writing model. The project currently provides functional Linking Books
and randomly seeded, persistent vanilla-like Ages created from page-free
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.
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`.
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 is now craftable from planks, a glass bottle, and a feather.
It occupies two adjacent blocks, with one shared inventory; loaded paper and
@@ -59,15 +64,56 @@ copy selection is checked by the server.
Selecting a stored page on the desk surface shows its Legacy-proportioned
portrait preview and name above the player inventory.
## 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
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
written Page on the cursor to insert it before that position; click it with an
empty cursor to take it out. The arrows or mouse wheel scroll through up to
27 written Pages. Clicking the strip with a filled Notebook imports its Pages
at that position and leaves the emptied Notebook on the cursor. The right slot
outputs a Descriptive Book once the title, cover, and Link Panel are present.
Inputs are consumed only when the result is taken.
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
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
Craft a Linking Book from a vanilla book and an ender pearl, then use it in the
air. An unbound book opens a screen where it can be named and bound to the
Craft an unwritten Linking Book from one Link Panel Page and one leather in any
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
its preview panel is clicked. The destination is stored on the item and
survives inventory and world saves. Travel only succeeds if the target
dimension is loaded and a non-fluid, passable landing position can be found
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
the departure and arrival locations.
@@ -143,6 +189,10 @@ known upstream limits are documented in
```
The built mod is written to `build/libs/`.
The default dedicated-server GameTest run is deliberately small. Use
`./gradlew runGameTestServer -PwithArchivedGameTests=true` for the complete
regression suite before a release or when investigating a related issue. See
[docs/GAMETESTS.md](docs/GAMETESTS.md).
## Versioning
+77 -7
View File
@@ -17,6 +17,18 @@ tasks.named('wrapper', Wrapper).configure {
version = mod_version
group = mod_group_id
List<String> archivedGameTestSuites = ['linking', 'writing', 'desk', 'binder', 'mixer', 'recipe']
List<String> selectedArchivedGameTestSuites = providers.gradleProperty('withArchivedGameTests')
.map { it.toBoolean() }.orElse(false).get() ? archivedGameTestSuites
: providers.gradleProperty('archivedGameTestSuites').orElse('').get()
.split(',').collect { it.trim() }.findAll { !it.isEmpty() }
if (!archivedGameTestSuites.containsAll(selectedArchivedGameTestSuites)) {
throw new GradleException("Unknown archived GameTest suite. Choose from: ${archivedGameTestSuites.join(', ')}")
}
String enabledGameTestNamespaces = ([mod_id] + selectedArchivedGameTestSuites.collect {
"rivencraft_archive_${it}"
}).join(',')
sourceSets.main.resources {
// Include resources generated by data generators.
srcDir('src/generated/resources')
@@ -26,7 +38,24 @@ sourceSets.main.resources {
exclude("src/generated/**/.cache") // datagen cache files
}
// Archived suites use separate namespaces but share the same empty structure.
tasks.named('processResources', ProcessResources).configure {
archivedGameTestSuites.each { suite ->
from('src/main/resources/data/rivencraft/structure/empty.nbt') {
into "data/rivencraft_archive_${suite}/structure"
}
}
}
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://www.cursemaven.com'
@@ -78,7 +107,10 @@ neoForge {
// The gametest system is also enabled by default for other run configs under the /test command.
gameTestServer {
type = "gameTestServer"
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
// Keep generated test Ages separate from worlds used by the dev client/server.
gameDirectory = project.file(project.findProperty('gameTestRunDir')
?: layout.buildDirectory.dir('gametest-run').get().asFile)
systemProperty 'neoforge.enabledGameTestNamespaces', enabledGameTestNamespaces
}
data {
@@ -116,6 +148,44 @@ neoForge {
}
}
// GameTests generate Ages, so their dedicated world can grow quickly across runs.
// Never auto-delete a caller-supplied gameTestRunDir or a normal dev world.
long gameTestWorldLimitMiB = providers.gradleProperty('gameTestMaxWorldMiB').orElse('512').get().toLong()
if (gameTestWorldLimitMiB <= 0) {
throw new GradleException('gameTestMaxWorldMiB must be a positive number')
}
boolean customGameTestRunDir = providers.gradleProperty('gameTestRunDir').isPresent()
File dedicatedGameTestDir = layout.buildDirectory.dir('gametest-run').get().asFile.canonicalFile
File dedicatedGameTestWorld = new File(dedicatedGameTestDir, 'world')
def cleanOversizedGameTestWorld = tasks.register('cleanOversizedGameTestWorld') {
doLast {
if (customGameTestRunDir) {
getLogger().lifecycle('Skipping automatic GameTest world cleanup for custom gameTestRunDir')
return
}
def worldDir = dedicatedGameTestWorld
if (!worldDir.isDirectory() || java.nio.file.Files.isSymbolicLink(worldDir.toPath())) {
return
}
def paths = java.nio.file.Files.walk(worldDir.toPath()).withCloseable { it.toList() }
long bytes = paths.sum(0L) { path ->
java.nio.file.Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)
? java.nio.file.Files.size(path) : 0L
}
long limit = gameTestWorldLimitMiB * 1024L * 1024L
if (bytes > limit) {
getLogger().lifecycle('Removing oversized GameTest world {} ({} MiB; limit {} MiB)',
worldDir, Math.ceil(bytes / 1048576d) as long, gameTestWorldLimitMiB)
paths.toSorted(java.util.Comparator.reverseOrder()).each { java.nio.file.Files.delete(it) }
}
}
}
tasks.named('runGameTestServer').configure {
dependsOn(cleanOversizedGameTestWorld)
}
// Sets up a dependency configuration called 'localRuntime'.
// This configuration should be used instead of 'runtimeOnly' to declare
// a dependency that will be present for runtime testing but that is
@@ -131,12 +201,12 @@ dependencies {
compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}"
implementation "curse.maven:dynamicdimension-ar-edition-1469680:${dynamic_dimensions_file_id}"
implementation "lol.bai:badpackets:neo-${badpackets_version}"
// Example optional mod dependency with JEI
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
// compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}"
// compileOnly "mezz.jei:jei-${mc_version}-neoforge-api:${jei_version}"
// We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it
// localRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}"
// JEI integration is isolated in its plugin and absent from the published runtime dependencies.
compileOnly "mezz.jei:jei-${minecraft_version}-neoforge-api:${jei_version}"
// Opt in to JEI for a dev client with -PwithJei=true.
if (providers.gradleProperty('withJei').map { it.toBoolean() }.orElse(false).get()) {
localRuntime "mezz.jei:jei-${minecraft_version}-neoforge:${jei_version}"
}
// Example mod dependency using a mod jar from ./libs with a flat dir repository
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 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

+9 -1
View File
@@ -8,11 +8,19 @@ 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/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/block/writing_desk.png` | `assets/mystcraft/textures/entity/desk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/block/writing_desk_wood.png` and `writing_desk_inlay.png` | `assets/mystcraft/textures/entity/desk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Cropped opaque wood and red inlay regions for the two-block model |
| `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/scrollbar.png` | `assets/mystcraft/gui/scrollbar.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; tiled track and normal/pressed thumb |
| `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/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/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 |
+42
View File
@@ -0,0 +1,42 @@
# GameTest suites
The default dedicated-server run is a small smoke suite: 16 Rivencraft tests
cover core linking, deterministic Age definition, writing persistence, legacy
data migration, workstation transactions, and forged/replayed menu actions.
NeoForge currently adds two of its own tests, so the runner reports 18.
```shell
./gradlew runGameTestServer
```
The remaining 46 Rivencraft tests are archived, not deleted. They cover
completed milestone details such as every Age terrain profile, Notebook and
Desk surface interactions, refill edge cases, book assembly controls, and
workstation break/drop behavior. Run only the relevant archive alongside the
core tests when investigating an issue:
```shell
./gradlew runGameTestServer -ParchivedGameTestSuites=desk
./gradlew runGameTestServer -ParchivedGameTestSuites=linking,binder
```
Available suite names are `linking`, `writing`, `desk`, `binder`, `mixer`, and
`recipe`. Run all 62 Rivencraft tests (64 including NeoForge's two) before a
release or after broad cross-system changes:
```shell
./gradlew runGameTestServer -PwithArchivedGameTests=true
```
Each archive is selected by its `rivencraft_archive_<suite>` GameTest namespace.
The suites share the normal empty test structure and do not need Create, Ponder, or
Immersive Portals. The core suite delegates to the original regression methods
for its selected cases, so the two suites do not maintain duplicate test logic.
GameTest Ages are created in `build/gametest-run/world`, not in a development
client save. The Gradle task removes that **dedicated** world automatically if
it exceeds 512 MiB; override the threshold with `-PgameTestMaxWorldMiB=128`,
for example. Cleanup never targets a custom `-PgameTestRunDir` or a player
world. A fresh test world can also help diagnose a slow or stalled worldgen
run. The full suite should still be run before shipping changes in the
archived systems.
+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
renderer compatibility. **Complete.** See
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
4. **Writing system** — pages, symbols, notebooks, desks, and book assembly.
**In progress.**
4. **Writing system** — pages, notebooks, desks, book assembly, and the baseline
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,
dimension-type, and world-generation configuration. Optional integrations
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
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
contracts preserve unknown symbol identifiers and store author intent; they do
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
+1 -2
View File
@@ -66,7 +66,7 @@ unstable in a particular graphics or mod configuration.
For preview-related releases:
1. Run `./gradlew runGameTestServer` to verify the complete selection matrix,
1. Run `./gradlew runGameTestServer -PwithArchivedGameTests=true` to verify the complete selection matrix,
core Age creation, persistence, and travel without optional renderers.
2. Open held Linking and Descriptive Books once with each available backend.
3. Open both book types from a Book Stand and confirm Explore is disabled.
@@ -75,4 +75,3 @@ For preview-related releases:
preview loader remains active.
5. With Ponder, reopen different destinations and confirm the previous
snapshot does not bleed into the next one.
+69 -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
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
resolved as client-safe **Unresolved Symbol** display metadata while retaining
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
removed but are rejected as usable writing until their data pack returns.
Only server-side writing operations may create a known symbol
page or link panel from vanilla paper. The shared `PageItem` creation and validation gates reject
new pages for identifiers absent from the server catalog. Later Writing Desk
and Book Binder packets must call these gates rather than accepting page
contents supplied by a client.
Only server-side writing operations may create a known symbol page or link
panel from vanilla paper. The shared `PageItem` creation and validation gates
reject new pages for identifiers absent from the server catalog. The Writing
Desk refuses to copy an unresolved page, and the Book Binder keeps such a page
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
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
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
An Ink Vial is crafted from two black dye, a glass bottle, and a water bucket.
@@ -91,3 +126,32 @@ 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
vanilla glass bottle. Link-property modifiers are
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.
The core and archived dedicated-server [GameTests](GAMETESTS.md) together
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
dynamic_dimensions_file_id=7707789
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
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.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.3.6.9
mod_version=0.4.0.2
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.
# 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.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.registry.ModRecipeSerializers;
import net.dnorberg.rivencraft.registry.ModSounds;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
@@ -43,6 +44,7 @@ public final class Rivencraft {
ModBlockEntities.register(modEventBus);
ModItems.register(modEventBus);
ModMenus.register(modEventBus);
ModRecipeSerializers.register(modEventBus);
ModSounds.register(modEventBus);
ModCreativeTabs.register(modEventBus);
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
@@ -0,0 +1,66 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.phys.BlockHitResult;
public final class BookBinderBlock extends BaseEntityBlock {
public static final MapCodec<BookBinderBlock> CODEC = simpleCodec(BookBinderBlock::new);
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
public BookBinderBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override public MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING);
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getHorizontalDirection().getOpposite();
return defaultBlockState().setValue(FACING, facing);
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new BookBinderBlockEntity(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 BookBinderBlockEntity binder) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new BookBinderMenu(id, inventory, binder),
Component.translatable("container.rivencraft.book_binder")),
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 BookBinderBlockEntity binder) {
Containers.dropContents(level, pos, binder);
level.updateNeighbourForOutputSignal(pos, this);
}
super.onRemove(state, level, pos, replacement, moving);
}
}
@@ -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);
}
}
@@ -0,0 +1,251 @@
package net.dnorberg.rivencraft.block.entity;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.server.level.ServerLevel;
/** Persistent inputs for a binder; the assembled output is never stored here. */
public final class BookBinderBlockEntity extends BlockEntity implements Container {
public static final int COVER = 0;
public static final int LINK_PANEL = 1;
public static final int NOTEBOOK = 2;
public static final int FIRST_PAGE = 3;
public static final int PAGE_COUNT = NotebookContents.MAX_PAGES;
public static final int SIZE = FIRST_PAGE + PAGE_COUNT;
public static final int MAX_TITLE_LENGTH = 32;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private String title = "";
private UUID pendingAgeId;
public BookBinderBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.BOOK_BINDER.get(), pos, state);
}
public String title() { return title; }
public void setTitle(String value) {
String normalized = value == null ? "" : value.strip();
if (normalized.length() > MAX_TITLE_LENGTH) normalized = normalized.substring(0, MAX_TITLE_LENGTH);
if (!normalized.equals(title)) {
title = normalized;
setChanged();
}
}
public static boolean validCover(ItemStack stack) {
return stack.is(Items.LEATHER) || stack.is(ModItems.NOTEBOOK.get())
&& stack.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount() == 0;
}
private static boolean validPage(ItemStack stack) {
return stack.is(ModItems.PAGE.get()) && PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
}
@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();
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();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return switch (slot) {
case COVER -> validCover(stack);
case LINK_PANEL -> stack.is(ModItems.PAGE.get())
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL;
case NOTEBOOK -> stack.is(ModItems.NOTEBOOK.get());
default -> slot >= FIRST_PAGE && slot < SIZE && validPage(stack);
};
}
@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;
}
/** All checks precede any mutation so a failed assembly leaves every input intact. */
public boolean canAssemble() {
if (title.isBlank() || !validCover(items.get(COVER))
|| !canPlaceItem(LINK_PANEL, items.get(LINK_PANEL))
|| items.get(LINK_PANEL).getCount() != 1) return false;
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
ItemStack page = items.get(slot);
if (!page.isEmpty() && (!validPage(page) || !PageItem.isUsableWriting(page) || page.getCount() != 1
|| PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false;
}
return true;
}
public ItemStack previewBook() {
if (!canAssemble()) return ItemStack.EMPTY;
if (pendingAgeId == null) {
pendingAgeId = UUID.randomUUID();
setChanged();
}
List<WritingEntry> entries = new ArrayList<>();
entries.add(WritingEntry.linkPanel());
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
ItemStack page = items.get(slot);
if (!page.isEmpty()) entries.add(WritingEntry.symbol(PageItem.content(page).symbol().orElseThrow()));
}
ItemStack book = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
book.set(ModDataComponents.AGE_REFERENCE.get(), new AgeReference(pendingAgeId, title));
book.set(ModDataComponents.BOOK_WRITING.get(), new OrderedWriting(entries));
book.set(DataComponents.CUSTOM_NAME, Component.literal(title));
return book;
}
/** Called once, on the server, after the output can be delivered. */
public boolean consumeAssembly() {
if (!canAssemble()) return false;
if (pendingAgeId == null) pendingAgeId = UUID.randomUUID();
if (level instanceof ServerLevel serverLevel)
AgeRegistrySavedData.resolveOrCreate(serverLevel, new AgeReference(pendingAgeId, title));
items.get(COVER).shrink(1);
if (items.get(COVER).isEmpty()) items.set(COVER, ItemStack.EMPTY);
items.get(LINK_PANEL).shrink(1);
if (items.get(LINK_PANEL).isEmpty()) items.set(LINK_PANEL, ItemStack.EMPTY);
for (int slot = FIRST_PAGE; slot < SIZE; slot++) items.set(slot, ItemStack.EMPTY);
title = "";
pendingAgeId = null;
setChanged();
return true;
}
public int nextPageIndex() {
for (int index = PAGE_COUNT - 1; index >= 0; index--) {
if (!items.get(FIRST_PAGE + index).isEmpty()) return index + 1;
}
return 0;
}
/** Insert before a displayed page, shifting later pages as Legacy's strip does. */
public boolean insertPage(int index, ItemStack page) {
int end = nextPageIndex();
if (index < 0 || index > end || end >= PAGE_COUNT || page.getCount() != 1
|| !validPage(page)) return false;
for (int current = end; current > index; current--)
items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current - 1));
items.set(FIRST_PAGE + index, page.copy());
setChanged();
return true;
}
/** Take a page back to the cursor, closing the gap in the displayed order. */
public ItemStack takePage(int index) {
int end = nextPageIndex();
if (index < 0 || index >= end || items.get(FIRST_PAGE + index).isEmpty()) return ItemStack.EMPTY;
ItemStack removed = items.get(FIRST_PAGE + index);
for (int current = index; current < end - 1; current++)
items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current + 1));
items.set(FIRST_PAGE + end - 1, ItemStack.EMPTY);
setChanged();
return removed;
}
/** Move every Notebook page as one transaction, leaving its empty cover in the input slot. */
public boolean importNotebook() {
ItemStack notebook = items.get(NOTEBOOK);
ItemStack emptied = importNotebookFromStack(notebook, nextPageIndex());
if (emptied.isEmpty()) return false;
items.set(NOTEBOOK, emptied);
setChanged();
return true;
}
/** Import a Notebook from either its stored slot or the player's cursor. */
public ItemStack importNotebookFromStack(ItemStack notebook, int index) {
if (!notebook.is(ModItems.NOTEBOOK.get()) || notebook.getCount() != 1) return ItemStack.EMPTY;
List<ItemStack> pages = notebook.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
if (pages.isEmpty()) return ItemStack.EMPTY;
int end = nextPageIndex();
if (index < 0 || index > end) return ItemStack.EMPTY;
ItemStack linkPanel = ItemStack.EMPTY;
List<ItemStack> writing = new ArrayList<>();
for (ItemStack page : pages) {
if (page.isEmpty()) continue;
if (!page.is(ModItems.PAGE.get()) || page.getCount() != 1) return ItemStack.EMPTY;
if (PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) {
if (!linkPanel.isEmpty() || !items.get(LINK_PANEL).isEmpty()) return ItemStack.EMPTY;
linkPanel = page;
} else if (validPage(page)) writing.add(page);
else return ItemStack.EMPTY;
}
if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && linkPanel.isEmpty()) return ItemStack.EMPTY;
if (!linkPanel.isEmpty()) items.set(LINK_PANEL, linkPanel.copy());
for (int current = end - 1; current >= index; current--)
items.set(FIRST_PAGE + current + writing.size(), items.get(FIRST_PAGE + current));
for (ItemStack page : writing) items.set(FIRST_PAGE + index++, page.copy());
ItemStack emptyNotebook = notebook.copy();
emptyNotebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
setChanged();
return emptyNotebook;
}
@Override public void clearContent() {
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
title = "";
pendingAgeId = null;
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);
title = tag.getString("Title");
pendingAgeId = tag.hasUUID("PendingAgeId") ? tag.getUUID("PendingAgeId") : null;
}
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, items, registries);
tag.putString("Title", title);
if (pendingAgeId != null) tag.putUUID("PendingAgeId", pendingAgeId);
}
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
@Override public void setChanged() {
super.setChanged();
if (level != null && !level.isClientSide) {
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
}
}
@@ -0,0 +1,121 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
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;
}
/** No server tick: refill only when ink, bottle space, or consumption changes. */
private void refillEmptyBasin() {
if (level != null && !level.isClientSide && inkRemaining == 0) {
int addedInk = InkVialExchange.exchange(items, INK, BOTTLES);
if (addedInk > 0) {
inkRemaining = addedInk;
setChanged();
}
}
}
public boolean canCreateLinkPanel() {
if (!items.get(PAPER).is(Items.PAPER)) return false;
int cost = WritingInkService.linkPanelCost();
return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, 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) {
inkRemaining += InkVialExchange.exchange(items, INK, BOTTLES);
}
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);
}
}
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModItems;
@@ -92,26 +93,15 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
};
}
private boolean hasBottleSpace() {
ItemStack returned = items.get(RETURN);
return returned.isEmpty() || returned.is(Items.GLASS_BOTTLE)
&& returned.getCount() < returned.getMaxStackSize();
}
/** Consume one vial only when the empty meter can return its glass bottle. */
private void refillEmptyInkBar() {
if (level == null || level.isClientSide || inkRemaining != 0 || !hasBottleSpace()) return;
ItemStack vial = items.get(INK);
if (!vial.is(ModItems.INK_VIAL.get())) return;
int ink = Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
if (ink == 0) return;
inkRemaining = ink;
vial.shrink(1);
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
ItemStack returned = items.get(RETURN);
if (returned.isEmpty()) items.set(RETURN, new ItemStack(Items.GLASS_BOTTLE));
else returned.grow(1);
setChanged();
if (level != null && !level.isClientSide && inkRemaining == 0) {
int addedInk = InkVialExchange.exchange(items, INK, RETURN);
if (addedInk > 0) {
inkRemaining = addedInk;
setChanged();
}
}
}
/** The output is virtual; inputs are committed only after a result can be given to a player. */
@@ -119,23 +109,14 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
ItemStack paper = items.get(PAPER);
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
int cost = WritingInkService.symbolCopyCost();
if (inkRemaining >= cost) return true;
ItemStack vial = items.get(INK);
return vial.is(ModItems.INK_VIAL.get()) && inkRemaining + InkVialItem.remaining(vial) >= cost
&& hasBottleSpace();
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
}
public boolean consumeCopyMaterials() {
if (!canConsumeCopyMaterials()) return false;
int cost = WritingInkService.symbolCopyCost();
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 returned = items.get(RETURN);
if (returned.isEmpty()) items.set(RETURN, new ItemStack(Items.GLASS_BOTTLE));
else returned.grow(1);
inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
}
inkRemaining -= cost;
removeItem(PAPER, 1);
@@ -148,7 +129,6 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
refillEmptyInkBar();
}
@Override public void clearContent() {
items.clear();
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
displayPage = ItemStack.EMPTY;
@@ -92,8 +92,8 @@ public final class ClientBookInteractionHandler {
return;
}
minecraft.setScreen(payload.fromStand()
? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits())
: new DescriptiveBookScreen(hand, payload.book(), payload.traits()));
? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits(), payload.writingSymbols())
: new DescriptiveBookScreen(hand, payload.book(), payload.traits(), payload.writingSymbols()));
});
}
}
@@ -15,6 +15,8 @@ import net.neoforged.neoforge.client.event.EntityRenderersEvent;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.dnorberg.rivencraft.client.screen.NotebookScreen;
import net.dnorberg.rivencraft.client.screen.WritingDeskScreen;
import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
import net.dnorberg.rivencraft.client.screen.InkMixerScreen;
import net.dnorberg.rivencraft.registry.ModMenus;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
@@ -32,6 +34,8 @@ public final class ClientEvents {
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new);
event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new);
event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new);
event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new);
}
@SubscribeEvent
@@ -0,0 +1,233 @@
package net.dnorberg.rivencraft.client.screen;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.network.SetBookBinderTitlePayload;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
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. */
public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMenu> {
private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/book_binder_legacy.png");
private static final ResourceLocation PAGE_ATLAS = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/notebook_pages.png");
private static final int STRIP_X = 7;
private static final int STRIP_Y = 45;
private static final int STRIP_WIDTH = 162;
private static final int STRIP_HEIGHT = 40;
private static final int PAGE_WIDTH = 26;
private static final int PAGE_HEIGHT = 34;
private static final int PAGE_STEP = 28;
private static final int VISIBLE_PAGES = 5;
private EditBox titleInput;
private int firstVisible;
private boolean stripClickPendingRelease;
public BookBinderScreen(BookBinderMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
imageWidth = 176;
imageHeight = 181;
inventoryLabelX = 8;
inventoryLabelY = 87;
}
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() {
super.init();
titleInput = new EditBox(font, leftPos + 9, topPos + 10, 112, 12,
Component.translatable("screen.rivencraft.book_binder.title"));
titleInput.setBordered(false);
titleInput.setTextColor(0xFFFFFFFF);
titleInput.setMaxLength(BookBinderBlockEntity.MAX_TITLE_LENGTH);
titleInput.setValue(menu.title());
titleInput.setHint(Component.translatable("screen.rivencraft.book_binder.title_hint"));
titleInput.setResponder(value -> PacketDistributor.sendToServer(
new SetBookBinderTitlePayload(menu.containerId, value)));
addRenderableWidget(titleInput);
}
@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);
if (menu.getSlot(BookBinderBlockEntity.LINK_PANEL).getItem().isEmpty()) {
float fade = (System.currentTimeMillis() % 4000L) / 2000F;
if (fade > 1F) fade = 2F - fade;
graphics.blit(BACKGROUND, leftPos + 27, topPos + 26,
18, 18, 176, 0, 30, 40, 256, 256);
graphics.setColor(1F, 0.3F, 0.3F, 0.8F * fade);
graphics.blit(BACKGROUND, leftPos + 27, topPos + 26,
18, 18, 176, 0, 30, 40, 256, 256);
graphics.setColor(1F, 1F, 1F, 1F);
}
graphics.fill(leftPos + 7, topPos + 9, leftPos + 123, topPos + 23, 0xFF000000);
graphics.renderOutline(leftPos + 7, topPos + 9, 116, 14,
titleInput == null || titleInput.getValue().isBlank() ? 0xFFFF3030 : 0xFFA0A0A0);
}
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
}
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
firstVisible = Math.min(firstVisible, maxFirstVisible());
graphics.enableScissor(leftPos + STRIP_X + 8, topPos + STRIP_Y,
leftPos + STRIP_X + STRIP_WIDTH - 8, topPos + STRIP_Y + STRIP_HEIGHT);
int count = visiblePageCount();
for (int visible = 0; visible < VISIBLE_PAGES; visible++) {
int index = firstVisible + visible;
if (index >= count) break;
drawPage(graphics, pageAtIndex(index), index,
leftPos + 16 + visible * PAGE_STEP, topPos + STRIP_Y + 3);
}
graphics.disableScissor();
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
&& mouseY >= topPos + 26 && mouseY < topPos + 44) {
graphics.renderTooltip(font,
Component.translatable("screen.rivencraft.book_binder.missing_panel"), mouseX, mouseY);
return;
}
int hovered = pageIndexAt(mouseX, mouseY);
if (hovered >= 0 && hovered < displayedPages() && !pageAtIndex(hovered).isEmpty())
graphics.renderTooltip(font, pageAtIndex(hovered), mouseX, mouseY);
else renderTooltip(graphics, mouseX, mouseY);
}
private ItemStack pageAtIndex(int index) {
return menu.getSlot(index == 0 ? BookBinderBlockEntity.LINK_PANEL
: BookBinderBlockEntity.FIRST_PAGE + index - 1).getItem();
}
private int displayedPages() {
for (int index = BookBinderBlockEntity.PAGE_COUNT; index >= 1; index--)
if (!pageAtIndex(index).isEmpty()) return index + 1;
return pageAtIndex(0).isEmpty() ? 0 : 1;
}
private int maxFirstVisible() {
return Math.max(0, visiblePageCount() - VISIBLE_PAGES);
}
private int visiblePageCount() {
return Math.min(BookBinderBlockEntity.PAGE_COUNT + 1,
displayedPages() + (menu.getCarried().isEmpty() ? 0 : 1));
}
private void drawPage(GuiGraphics graphics, ItemStack page, int index, int x, int y) {
graphics.blit(PAGE_ATLAS, x, y, PAGE_WIDTH, PAGE_HEIGHT, 156, 0, 30, 40, 256, 256);
if (page.isEmpty()) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xAA000000);
return;
}
switch (PageItem.content(page).kind()) {
case BLANK -> { }
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000);
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page),
x + 1, y + 5, 24, 24, 16, 16, 96, 96, 128, 128);
}
}
private void drawScrollEnds(GuiGraphics graphics) {
int x = leftPos + STRIP_X;
int y = topPos + STRIP_Y;
graphics.fill(x, y, x + 8, y + STRIP_HEIGHT,
firstVisible == 0 ? 0x33000000 : 0xAA000000);
graphics.fill(x + STRIP_WIDTH - 8, y, x + STRIP_WIDTH, y + STRIP_HEIGHT,
firstVisible >= maxFirstVisible() ? 0x33000000 : 0xAA000000);
}
private int pageIndexAt(double mouseX, double mouseY) {
int localX = (int) mouseX - leftPos;
int localY = (int) mouseY - topPos;
if (localX < STRIP_X + 8 || localX >= STRIP_X + STRIP_WIDTH - 8
|| localY < STRIP_Y || localY >= STRIP_Y + STRIP_HEIGHT) return -1;
int visible = (localX - 16) / PAGE_STEP;
if (visible < 0 || visible >= VISIBLE_PAGES) return -1;
return Math.min(firstVisible + visible, BookBinderBlockEntity.PAGE_COUNT);
}
@Override public boolean mouseClicked(double mouseX, double mouseY, int button) {
int localX = (int) mouseX - leftPos;
int localY = (int) mouseY - topPos;
if ((button == 0 || button == 1) && localY >= STRIP_Y && localY < STRIP_Y + STRIP_HEIGHT
&& localX >= STRIP_X && localX < STRIP_X + STRIP_WIDTH) {
stripClickPendingRelease = true;
if (localX < STRIP_X + 8) firstVisible = Math.max(0, firstVisible - 1);
else if (localX >= STRIP_X + STRIP_WIDTH - 8)
firstVisible = Math.min(maxFirstVisible(), firstVisible + 1);
else {
int index = pageIndexAt(mouseX, mouseY);
if (index >= 0 && index <= displayedPages()
&& (index < displayedPages() || !menu.getCarried().isEmpty()))
clickStrip(index);
}
return true;
}
return super.mouseClicked(mouseX, mouseY, button);
}
private void clickStrip(int index) {
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
BookBinderMenu.STRIP_CLICK_BASE + index);
}
@Override public boolean mouseReleased(double mouseX, double mouseY, int button) {
if (stripClickPendingRelease) {
stripClickPendingRelease = false;
return true;
}
return super.mouseReleased(mouseX, mouseY, button);
}
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + STRIP_WIDTH
&& mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
firstVisible = Math.clamp(firstVisible - (int) Math.signum(scrollY), 0, maxFirstVisible());
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (titleInput != null && titleInput.isFocused()) {
if (keyCode == 256) {
setFocused(null);
titleInput.setFocused(false);
} else titleInput.keyPressed(keyCode, scanCode, modifiers);
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);
}
}
@@ -1,5 +1,7 @@
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.AgeTraits;
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.ReleaseLinkPreviewPayload;
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.DestinationPreviewRenderers;
import net.minecraft.core.BlockPos;
import net.minecraft.client.gui.GuiGraphics;
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.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.InteractionHand;
import net.neoforged.neoforge.network.PacketDistributor;
import org.lwjgl.glfw.GLFW;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
public final class DescriptiveBookScreen extends Screen implements ExplorableBookScreen {
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 UNRESOLVED_TEXT_COLOR = 0xFF9B302C;
private static final ResourceLocation ENCHANTMENT_FONT = ResourceLocation.withDefaultNamespace("alt");
private static final int BOOK_WIDTH = 327;
private static final int BOOK_HEIGHT = 199;
private static final int TEXTURE_WIDTH = 512;
private static final int TEXTURE_HEIGHT = 312;
private static final int LINK_PANEL_X = 28;
private static final int LINK_PANEL_Y = 35;
private static final int LINK_PANEL_WIDTH = 126;
private static final int LINK_PANEL_HEIGHT = 86;
private static final int LINK_PANEL_X = 173;
private static final int LINK_PANEL_Y = 20;
private static final int LINK_PANEL_WIDTH = 132;
private static final int LINK_PANEL_HEIGHT = 83;
private static final Component EXPLORE_ICON = Component.literal("👁");
private final AgeReference age;
private final ResourceLocation generationProfile;
private final AgeTraits traits;
private final OrderedWriting writing;
private final List<SymbolDisplayMetadata> writingSymbols;
private final boolean pageFree;
private final InteractionHand hand;
private final BlockPos bookStandPos;
private DestinationPreviewRenderer preview;
private EditBox titleInput;
private Button exploreButton;
private Button createButton;
private Button cancelButton;
private int pageIndex;
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());
age = book.get(ModDataComponents.AGE_REFERENCE.get());
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
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;
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());
age = book.get(ModDataComponents.AGE_REFERENCE.get());
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
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;
this.bookStandPos = bookStandPos.immutable();
}
@@ -75,19 +93,22 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
if (age == null) {
int rightPage = left + 177;
titleInput = new EditBox(font, rightPage, top + 69, 124, 20,
int leftPage = left + 40;
titleInput = new EditBox(font, leftPage, top + 69, 110, 20,
Component.translatable("screen.rivencraft.age.title"));
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.translatable("screen.rivencraft.age.title_hint"));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.age.create"), button -> createAge())
.bounds(rightPage, top + 100, 59, 20)
createButton = addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.age.create"), button -> createAge())
.bounds(leftPage, top + 100, 53, 20)
.build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(rightPage + 65, top + 100, 59, 20)
cancelButton = addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(leftPage + 57, top + 100, 53, 20)
.build());
titleInput.visible = pageIndex == 0;
createButton.visible = pageIndex == 0;
cancelButton.visible = pageIndex == 0;
} else {
boolean requestPreview = preview == null;
if (requestPreview) {
@@ -96,8 +117,10 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
if (preview.supportsExploration()) {
exploreButton = addRenderableWidget(Button.builder(
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.visible = pageIndex == 0;
}
if (requestPreview) {
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) {
renderBook(graphics, 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) {
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
graphics.blit(BOOK_TEXTURE, left, top, BOOK_WIDTH, BOOK_HEIGHT, 0, 0,
TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT);
LegacyBookArtwork.drawCover(graphics, left, top, true);
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) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.name_prompt"),
left + 177, top + 45, 124);
} else {
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);
}
x, top + 52, 110);
return;
}
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
@@ -201,14 +285,25 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
resetExploration();
return true;
}
if (age != null && button == 0) {
int panelLeft = (width - BOOK_WIDTH) / 2 + LINK_PANEL_X;
int panelTop = (height - BOOK_HEIGHT) / 2 + LINK_PANEL_Y;
if (mouseX >= panelLeft && mouseX < panelLeft + LINK_PANEL_WIDTH
&& mouseY >= panelTop && mouseY < panelTop + LINK_PANEL_HEIGHT) {
if (button == 0) {
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
if (pageIndex == 0 && age != null
&& 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();
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);
}
@@ -224,6 +319,10 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
@Override
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)
|| super.keyPressed(keyCode, scanCode, modifiers);
}
@@ -268,7 +367,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
int top = (height - BOOK_HEIGHT) / 2;
graphics.drawCenteredString(font,
Component.translatable("screen.rivencraft.book.return_from_explore"),
left + 91, top + 154, BOOK_TEXT_COLOR);
left + 239, top + 154, BOOK_TEXT_COLOR);
}
@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) {
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) {
@@ -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) {
for (FormattedCharSequence line : font.split(text, maxWidth)) {
graphics.drawString(font, line, x, y, BOOK_TEXT_COLOR, false);
return drawWrappedWithoutShadow(graphics, text, x, y, maxWidth, BOOK_TEXT_COLOR);
}
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;
}
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.components.Button;
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.resources.sounds.SimpleSoundInstance;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
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.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.PacketDistributor;
import org.lwjgl.glfw.GLFW;
public final class LinkingBookScreen extends Screen implements ExplorableBookScreen {
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_HEIGHT = 199;
private static final int TEXTURE_WIDTH = 512;
private static final int TEXTURE_HEIGHT = 312;
private static final int PREVIEW_X = 28;
private static final int PREVIEW_Y = 35;
private static final int PREVIEW_WIDTH = 126;
private static final int PREVIEW_HEIGHT = 86;
private static final int PREVIEW_X = 173;
private static final int PREVIEW_Y = 20;
private static final int PREVIEW_WIDTH = 132;
private static final int PREVIEW_HEIGHT = 83;
private static final Component EXPLORE_ICON = Component.literal("👁");
private final InteractionHand hand;
@@ -47,6 +47,9 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
private DestinationPreviewRenderer preview;
private EditBox titleInput;
private Button exploreButton;
private Button bindButton;
private Button cancelButton;
private int pageIndex;
private boolean movingToExploreOverlay;
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 top = (height - BOOK_HEIGHT) / 2;
if (target == null) {
int rightPage = left + 177;
titleInput = new EditBox(font, rightPage, top + 69, 124, 20,
int leftPage = left + 40;
titleInput = new EditBox(font, leftPage, top + 69, 110, 20,
Component.translatable("screen.rivencraft.book.title"));
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.translatable("screen.rivencraft.book.title_hint"));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.book.bind"), button -> bind())
.bounds(rightPage, top + 100, 59, 20)
bindButton = addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.book.bind"), button -> bind())
.bounds(leftPage, top + 100, 53, 20)
.build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(rightPage + 65, top + 100, 59, 20)
cancelButton = addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(leftPage + 57, top + 100, 53, 20)
.build());
titleInput.visible = pageIndex == 0;
bindButton.visible = pageIndex == 0;
cancelButton.visible = pageIndex == 0;
} else {
boolean requestPreview = preview == null;
if (requestPreview) {
@@ -91,8 +97,10 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
if (preview.supportsExploration()) {
exploreButton = addRenderableWidget(Button.builder(
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.visible = pageIndex == 0;
}
if (requestPreview) {
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) {
renderBook(graphics, 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) {
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
graphics.blit(BOOK_TEXTURE, left, top, BOOK_WIDTH, BOOK_HEIGHT, 0, 0,
TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT);
if (target == null) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.book.name_prompt"),
left + 177, top + 45, 124);
LegacyBookArtwork.drawCover(graphics, left, top, false);
if (pageIndex > 0) {
LegacyBookArtwork.drawLeftPage(graphics, left, top);
} else {
int previewLeft = left + PREVIEW_X;
int previewTop = top + PREVIEW_Y;
preview.render(graphics, previewLeft, previewTop, PREVIEW_WIDTH, PREVIEW_HEIGHT, partialTick);
int rightCenter = left + 239;
drawWrappedWithoutShadow(graphics, title, left + 177, top + 43, 124);
BlockPos pos = target.location().pos();
drawCenteredWithoutShadow(graphics,
LinkingBookItem.destinationName(book, target), rightCenter, top + 76);
drawCenteredWithoutShadow(graphics,
Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()), rightCenter, top + 89);
drawCenteredWithoutShadow(graphics,
Component.translatable("screen.rivencraft.book.click_to_link"), rightCenter, top + 119);
if (target != null && preview != null) {
preview.render(graphics, left + PREVIEW_X, top + PREVIEW_Y,
PREVIEW_WIDTH, PREVIEW_HEIGHT, partialTick);
}
LegacyBookArtwork.drawPanelPage(graphics, left, top);
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
if (target == null) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.book.name_prompt"),
left + 40, top + 43, 110);
} else {
int x = left + 40;
Component customName = book.get(DataComponents.CUSTOM_NAME);
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
@@ -163,18 +193,39 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
resetExploration();
return true;
}
if (target != null && button == 0) {
int left = (width - BOOK_WIDTH) / 2 + PREVIEW_X;
int top = (height - BOOK_HEIGHT) / 2 + PREVIEW_Y;
if (mouseX >= left && mouseX < left + PREVIEW_WIDTH
&& mouseY >= top && mouseY < top + PREVIEW_HEIGHT) {
if (button == 0) {
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
if (pageIndex == 0 && target != null
&& mouseX >= left + PREVIEW_X && mouseX < left + PREVIEW_X + PREVIEW_WIDTH
&& mouseY >= top + PREVIEW_Y && mouseY < top + PREVIEW_Y + PREVIEW_HEIGHT) {
link();
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);
}
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
public void removed() {
if (preview != null && !movingToExploreOverlay) {
@@ -197,6 +248,10 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
@Override
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)
|| super.keyPressed(keyCode, scanCode, modifiers);
}
@@ -247,7 +302,7 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
int top = (height - BOOK_HEIGHT) / 2;
drawCenteredWithoutShadow(graphics,
Component.translatable("screen.rivencraft.book.return_from_explore"),
left + 91, top + 154);
left + 239, top + 154);
}
@Override
@@ -47,6 +47,8 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
inventoryLabelY = 123;
}
@Override public boolean isPauseScreen() { return false; }
@Override
protected void init() {
super.init();
@@ -15,12 +15,14 @@ import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.Slot;
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. */
public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> {
@@ -60,12 +62,15 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
titleLabelX = 222;
}
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() {
super.init();
sortButton = addRenderableWidget(Button.builder(Component.literal("AZ"), button -> {
sortAlphabetically = !sortAlphabetically;
scrollRows = 0;
}).bounds(leftPos + 58, topPos, 20, 18).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 -> {
allMode = !allMode;
scrollRows = 0;
@@ -75,7 +80,8 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
if (minecraft != null && minecraft.gameMode != null)
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,
Component.translatable("screen.rivencraft.writing_desk.search"));
search.setMaxLength(64);
@@ -203,8 +209,17 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
}
if (mouseX >= leftPos + 213 && mouseX < leftPos + 231
&& mouseY >= topPos && mouseY < topPos + 18) {
graphics.renderTooltip(font, Component.literal(selectedCategory == null
? "All categories" : selectedCategory.toString()), mouseX, mouseY);
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.category",
selectedCategory == null
? Component.translatable("screen.rivencraft.writing_desk.all_categories")
: Component.literal(selectedCategory.toString())), mouseX, mouseY);
}
if (mouseX >= leftPos && mouseX < leftPos + 58) {
int localY = mouseY - topPos;
if (localY >= 20 && localY < 29)
graphics.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;
}
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);
}
}
@@ -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);
}
}
}
@@ -0,0 +1,344 @@
package net.dnorberg.rivencraft.gametest;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder("rivencraft_archive_binder")
@PrefixGameTestTemplate(false)
public final class BookBinderGameTests {
private BookBinderGameTests() {}
private static ItemStack terrainPage() {
return PageItem.createSymbolPage(new SymbolReference(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/standard")));
}
private static ItemStack weatherPage() {
return PageItem.createSymbolPage(new SymbolReference(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "weather/normal")));
}
private static BookBinderBlockEntity place(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.BOOK_BINDER.get().defaultBlockState(), 3);
return (BookBinderBlockEntity) helper.getLevel().getBlockEntity(pos);
}
@GameTest(template = "empty")
public static void binderInputsAndTitleSurviveSaveLoad(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setTitle("Ordered Age");
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage());
var saved = binder.saveWithoutMetadata(helper.getLevel().registryAccess());
var restored = new BookBinderBlockEntity(binder.getBlockPos(), binder.getBlockState());
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
helper.assertValueEqual(restored.title(), "Ordered Age", "Title must persist");
helper.assertTrue(restored.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER), "Cover must persist");
helper.assertTrue(restored.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()),
"Link Panel must persist");
helper.assertTrue(ItemStack.matches(restored.getItem(BookBinderBlockEntity.FIRST_PAGE), terrainPage()),
"Ordered page must persist");
helper.assertTrue(restored.canAssemble(), "Restored binder must remain ready to assemble");
AgeReference beforeSave = binder.previewBook().get(ModDataComponents.AGE_REFERENCE.get());
var previewSaved = binder.saveWithoutMetadata(helper.getLevel().registryAccess());
restored.loadWithComponents(previewSaved, helper.getLevel().registryAccess());
helper.assertValueEqual(restored.previewBook().get(ModDataComponents.AGE_REFERENCE.get()), beforeSave,
"Pending Age identity must survive a world restart");
helper.succeed();
}
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();
}
public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setTitle("A New Age");
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage());
binder.setItem(BookBinderBlockEntity.FIRST_PAGE + 1, weatherPage());
helper.assertFalse(binder.canAssemble(), "Missing Link Panel must prevent assembly");
helper.assertFalse(binder.consumeAssembly(), "Failure must consume nothing");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER),
"Rejected assembly must preserve cover");
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
binder.getBlockPos().getZ() + 0.5);
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(BookBinderMenu.RESULT).hasItem(), "Valid binder must show output");
menu.clicked(BookBinderMenu.RESULT, 0, ClickType.PICKUP, player);
ItemStack book = menu.getCarried();
helper.assertTrue(book.is(ModItems.DESCRIPTIVE_BOOK.get()), "Result must be a Descriptive Book");
AgeReference age = book.get(ModDataComponents.AGE_REFERENCE.get());
helper.assertTrue(age != null, "Assembled book must already be a written Age book");
OrderedWriting writing = book.get(ModDataComponents.BOOK_WRITING.get());
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().get(0).kind(), WritingEntry.Kind.LINK_PANEL,
"Link Panel must be first");
helper.assertValueEqual(writing.entries().get(1).symbol().orElseThrow().id().toString(),
"rivencraft:terrain/standard", "Terrain page order must be preserved");
helper.assertValueEqual(writing.entries().get(2).symbol().orElseThrow().id().toString(),
"rivencraft:weather/normal", "Weather page order must be preserved");
helper.assertTrue(AgeRegistrySavedData.get(helper.getLevel()).find(age.id()).isPresent(),
"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.LINK_PANEL).isEmpty(), "Link Panel must be consumed once");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE).isEmpty(), "Pages must be consumed once");
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty(), "No duplicate output may remain");
helper.succeed();
}
@GameTest(template = "empty")
public static void linkPanelOnlyCreatesCompletedRandomAgeBook(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setTitle("Random Binder Age");
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
binder.getBlockPos().getZ() + 0.5);
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
menu.clicked(BookBinderMenu.RESULT, 0, ClickType.PICKUP, player);
ItemStack book = menu.getCarried();
AgeReference age = book.get(ModDataComponents.AGE_REFERENCE.get());
helper.assertTrue(age != null, "Link-Panel-only output must be written, not unwritten");
helper.assertValueEqual(age.name(), "Random Binder Age", "The assigned title must name the Age");
helper.assertValueEqual(book.get(ModDataComponents.BOOK_WRITING.get()).entries().size(), 1,
"A random Age book must contain only its Link Panel when no symbols are supplied");
helper.assertTrue(AgeRegistrySavedData.get(helper.getLevel()).find(age.id()).isPresent(),
"Taking the book must register its random Age definition");
helper.assertTrue(AgeDimensionService.resolve(player, age).isPresent(),
"The completed book must resolve to a usable random Age");
helper.succeed();
}
@GameTest(template = "empty")
public static void notebookImportPreservesOrderAndRejectsOverflow(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(
PageItem.createLinkPanelPage(), terrainPage(), weatherPage())));
binder.setItem(BookBinderBlockEntity.NOTEBOOK, notebook);
helper.assertTrue(binder.importNotebook(), "Notebook pages must import together");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()),
"Imported Link Panel must use its dedicated slot");
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), terrainPage()),
"First Notebook symbol must stay first");
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), weatherPage()),
"Second Notebook symbol must stay second");
helper.assertValueEqual(binder.getItem(BookBinderBlockEntity.NOTEBOOK)
.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount(), 0,
"Empty Notebook cover must remain recoverable");
for (int slot = BookBinderBlockEntity.FIRST_PAGE + 2; slot < BookBinderBlockEntity.SIZE; slot++)
binder.setItem(slot, terrainPage());
ItemStack overflow = new ItemStack(ModItems.NOTEBOOK.get());
overflow.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(weatherPage())));
binder.setItem(BookBinderBlockEntity.NOTEBOOK, overflow);
helper.assertFalse(binder.importNotebook(), "Full binder must reject Notebook import");
helper.assertValueEqual(binder.getItem(BookBinderBlockEntity.NOTEBOOK)
.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount(), 1,
"Rejected import must preserve the Notebook page");
helper.succeed();
}
@GameTest(template = "empty")
public static void pageControlsAndBreakingPreserveStoredInputs(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage());
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
binder.getBlockPos().getZ() + 0.5);
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
helper.assertFalse(menu.clickMenuButton(player, BookBinderMenu.MOVE_PREVIOUS),
"Move without selection must be rejected");
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.SELECT_BASE), "Page selection must succeed");
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.MOVE_NEXT), "Page move must succeed");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE).isEmpty(), "Old position must be empty");
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), terrainPage()),
"Moved page must keep its data");
helper.assertFalse(menu.clickMenuButton(player, 999), "Unknown action must be rejected");
BlockPos pos = binder.getBlockPos();
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(menu.stillValid(player), "Removed binder must invalidate the menu");
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(2));
int leather = drops.stream().filter(entity -> entity.getItem().is(Items.LEATHER))
.mapToInt(entity -> entity.getItem().getCount()).sum();
int pages = drops.stream().filter(entity -> entity.getItem().is(ModItems.PAGE.get()))
.mapToInt(entity -> entity.getItem().getCount()).sum();
helper.assertValueEqual(leather, 1, "Cover must drop once");
helper.assertValueEqual(pages, 2, "Link Panel and writing page must drop once each");
helper.succeed();
}
@GameTest(template = "empty")
public static void fullInventoryCannotConsumeBinderInputs(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setTitle("Full Inventory");
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
binder.getBlockPos().getZ() + 0.5);
for (int slot = 0; slot < 36; slot++)
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
helper.assertTrue(menu.quickMoveStack(player, BookBinderMenu.RESULT).isEmpty(),
"Full inventory must reject output transfer");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER),
"Rejected transfer must preserve cover");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()),
"Rejected transfer must preserve Link Panel");
helper.assertTrue(menu.getSlot(BookBinderMenu.RESULT).hasItem(),
"Result must remain available after inventory space is freed");
helper.assertFalse(menu.setTitle(player, "A title that is much too long for the binder slot"),
"Oversized title packet must be rejected");
helper.assertValueEqual(binder.title(), "Full Inventory", "Rejected title must preserve existing title");
helper.succeed();
}
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")
public static void legacyStripInsertsAndRemovesPagesInOrder(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
binder.getBlockPos().getZ() + 0.5);
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
menu.setCarried(PageItem.createLinkPanelPage());
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE),
"The first thumbnail must accept a Link Panel");
helper.assertTrue(menu.getCarried().isEmpty(), "Inserted panel must leave the cursor");
menu.setCarried(terrainPage());
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1),
"The strip must accept a written Page");
menu.setCarried(weatherPage());
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1),
"Inserting before a Page must shift it right");
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), weatherPage()),
"Inserted Page must be first");
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), terrainPage()),
"Existing Page must move right");
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1),
"Clicking a thumbnail with an empty cursor must take the Page");
helper.assertTrue(ItemStack.matches(menu.getCarried(), weatherPage()),
"Removed Page must return to the cursor");
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), terrainPage()),
"Removal must close the gap");
helper.assertFalse(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE),
"A symbol Page must not replace the Link Panel");
helper.succeed();
}
@GameTest(template = "empty")
public static void legacyStripImportsNotebookAtClickedPosition(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage());
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
binder.getBlockPos().getZ() + 0.5);
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(weatherPage())));
menu.setCarried(notebook);
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1),
"Notebook pages must import where clicked");
helper.assertValueEqual(menu.getCarried().getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount(), 0,
"The empty Notebook must remain on the cursor");
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), weatherPage()),
"Imported Page must occupy the clicked position");
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), terrainPage()),
"Existing Page must shift right");
helper.succeed();
}
}
@@ -0,0 +1,94 @@
package net.dnorberg.rivencraft.gametest;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Small default smoke suite; the detailed milestone regressions are opt-in. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class CoreGameTests {
private CoreGameTests() {}
@GameTest(template = "empty")
public static void previewBackendSelection(GameTestHelper helper) {
LinkingGameTests.previewBackendSelectionCoversOptionalDependencyMatrix(helper);
}
@GameTest(template = "empty")
public static void linkTargetPersistence(GameTestHelper helper) {
LinkingGameTests.linkTargetSurvivesItemSerialization(helper);
}
@GameTest(template = "empty")
public static void deterministicAgeDefinition(GameTestHelper helper) {
LinkingGameTests.ageDefinitionIsCreatedOnceWithDeterministicData(helper);
}
@GameTest(template = "empty")
public static void blockedLinkDestination(GameTestHelper helper) {
LinkingGameTests.travelRejectsBlockedDestination(helper);
}
@GameTest(template = "empty")
public static void legacyWritingAndPageFreeBooks(GameTestHelper helper) {
WritingDataGameTests.oldBareListWritingAndPageFreeBooksRemainReadable(helper);
}
@GameTest(template = "empty")
public static void pagePersistenceAndUnknownSymbols(GameTestHelper helper) {
WritingDataGameTests.pageItemPreservesContentAndRejectsForgedUnknownSymbols(helper);
}
@GameTest(template = "empty")
public static void notebookPersistence(GameTestHelper helper) {
WritingDataGameTests.notebookContentsPreserveOrderGapsNamesAndUnknownPages(helper);
}
@GameTest(template = "empty")
public static void deskCopyTransaction(GameTestHelper helper) {
WritingDeskGameTests.takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(helper);
}
@GameTest(template = "empty")
public static void deskRejectsForgedAndStaleActions(GameTestHelper helper) {
WritingDeskGameTests.forgedDeskSelectionsAndStaleMenuCannotCopy(helper);
}
@GameTest(template = "empty")
public static void twoBlockDeskDropsOnce(GameTestHelper helper) {
WritingDeskGameTests.twoBlockDeskSharesOneInventoryAndBreaksTogether(helper);
}
@GameTest(template = "empty")
public static void oldDeskMigration(GameTestHelper helper) {
WritingDeskGameTests.oldDeskInventoryMigratesAndAll88TabsPersist(helper);
}
@GameTest(template = "empty")
public static void binderKeepsMissingSymbolsRecoverable(GameTestHelper helper) {
BookBinderGameTests.missingSymbolPageSurvivesReloadButCannotBeAssembled(helper);
}
@GameTest(template = "empty")
public static void binderAssemblyTransaction(GameTestHelper helper) {
BookBinderGameTests.assemblyIsAtomicAndRetainsPageOrder(helper);
}
@GameTest(template = "empty")
public static void binderRejectsForgedAndReplayedActions(GameTestHelper helper) {
BookBinderGameTests.forgedBinderActionsAndReplayedOutputCannotDuplicateBook(helper);
}
@GameTest(template = "empty")
public static void mixerOutputTransaction(GameTestHelper helper) {
InkMixerGameTests.linkPanelRequiresTakingOutputAndPersistsInk(helper);
}
@GameTest(template = "empty")
public static void linkingBookRecipe(GameTestHelper helper) {
LinkingBookRecipeGameTests.legacyRecipeRequiresExactlyLeatherAndLinkPanel(helper);
}
}
@@ -0,0 +1,111 @@
package net.dnorberg.rivencraft.gametest;
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_archive_mixer")
@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);
}
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,65 @@
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_archive_recipe")
@PrefixGameTestTemplate(false)
public final class LinkingBookRecipeGameTests {
private LinkingBookRecipeGameTests() {}
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();
}
}
@@ -34,7 +34,7 @@ import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Server-side regression coverage for persistent linking-book data and travel safety. */
@GameTestHolder(Rivencraft.MOD_ID)
@GameTestHolder("rivencraft_archive_linking")
@PrefixGameTestTemplate(false)
public final class LinkingGameTests {
private static final BlockPos ORIGIN = new BlockPos(0, 1, 0);
@@ -42,7 +42,6 @@ public final class LinkingGameTests {
private LinkingGameTests() {
}
@GameTest(template = "empty")
public static void previewBackendSelectionCoversOptionalDependencyMatrix(GameTestHelper helper) {
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, true,
PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
@@ -127,7 +126,6 @@ public final class LinkingGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void linkTargetSurvivesItemSerialization(GameTestHelper helper) {
LinkTarget expected = new LinkTarget(
GlobalPos.of(helper.getLevel().dimension(), helper.absolutePos(ORIGIN)),
@@ -181,7 +179,6 @@ public final class LinkingGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void ageDefinitionIsCreatedOnceWithDeterministicData(GameTestHelper helper) {
UUID ageId = UUID.randomUUID();
String requestedName = "Test Age " + ageId;
@@ -666,7 +663,6 @@ public final class LinkingGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void travelRejectsBlockedDestination(GameTestHelper helper) {
BlockPos absoluteOrigin = helper.absolutePos(ORIGIN);
for (BlockPos position : BlockPos.betweenClosed(
@@ -3,7 +3,8 @@ package net.dnorberg.rivencraft.gametest;
import com.mojang.serialization.JsonOps;
import io.netty.buffer.Unpooled;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import java.util.UUID;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
@@ -29,7 +30,7 @@ import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Acceptance coverage for Milestone 4's persistent writing contracts. */
@GameTestHolder(Rivencraft.MOD_ID)
@GameTestHolder("rivencraft_archive_writing")
@PrefixGameTestTemplate(false)
public final class WritingDataGameTests {
private WritingDataGameTests() {
@@ -46,6 +47,8 @@ public final class WritingDataGameTests {
));
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();
helper.assertValueEqual(persistentCopy, original,
"Persistent writing codec must retain entry order and unknown symbol IDs");
@@ -58,6 +61,27 @@ public final class WritingDataGameTests {
helper.succeed();
}
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")
public static void serverCatalogResolvesBuiltinsAndKeepsUnknownReferencesReadable(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
@@ -100,7 +124,6 @@ public final class WritingDataGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void pageItemPreservesContentAndRejectsForgedUnknownSymbols(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference unknown = reference("example:forged_symbol");
@@ -116,6 +139,10 @@ public final class WritingDataGameTests {
);
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),
"An unresolved page must remain recoverable without becoming valid writing");
try {
@@ -126,7 +153,6 @@ public final class WritingDataGameTests {
}
}
@GameTest(template = "empty")
public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) {
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
@@ -1,7 +1,6 @@
package net.dnorberg.rivencraft.gametest;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
@@ -26,7 +25,7 @@ import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@GameTestHolder("rivencraft_archive_desk")
@PrefixGameTestTemplate(false)
public final class WritingDeskGameTests {
private WritingDeskGameTests() {}
@@ -94,7 +93,6 @@ public final class WritingDeskGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
@@ -249,11 +247,39 @@ public final class WritingDeskGameTests {
"First viewer should take the last available copy");
helper.assertTrue(secondMenu.quickMoveStack(second, WritingDeskMenu.COPY_RESULT).isEmpty(),
"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(),
"Shared desk must spend paper only once");
helper.succeed();
}
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")
public static void catalogAggregatesKnownSymbolsAcrossCollections(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
@@ -310,7 +336,6 @@ public final class WritingDeskGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void twoBlockDeskSharesOneInventoryAndBreaksTogether(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var block = ModBlocks.WRITING_DESK.get();
@@ -433,7 +458,6 @@ public final class WritingDeskGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void oldDeskInventoryMigratesAndAll88TabsPersist(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var registries = helper.getLevel().registryAccess();
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.item;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.PageContent;
@@ -28,6 +29,23 @@ public final class PageItem extends Item {
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() {
return create(PageContent.LINK_PANEL);
}
@@ -0,0 +1,224 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.PageContent;
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.ClickType;
import net.minecraft.world.inventory.DataSlot;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Fixed, ordered binder slots and a server-owned virtual result. */
public final class BookBinderMenu extends AbstractContainerMenu {
public static final int RESULT = BookBinderBlockEntity.SIZE;
public static final int SELECT_BASE = 100;
public static final int MOVE_PREVIOUS = 200;
public static final int MOVE_NEXT = 201;
public static final int IMPORT_NOTEBOOK = 202;
public static final int STRIP_CLICK_BASE = 300;
private static final int PLAYER_START = RESULT + 1;
private final BookBinderBlockEntity binder;
private final Container inputs;
private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide;
private int selectedPage = -1;
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof BookBinderBlockEntity found ? found : null);
}
public BookBinderMenu(int id, Inventory inventory, BookBinderBlockEntity binder) {
super(ModMenus.BOOK_BINDER.get(), id);
this.binder = binder;
inputs = binder == null ? new SimpleContainer(BookBinderBlockEntity.SIZE) : binder;
clientSide = inventory.player.level().isClientSide;
addDataSlot(new DataSlot() {
@Override public int get() { return selectedPage; }
@Override public void set(int value) { selectedPage = value; }
});
addSlot(new BinderSlot(BookBinderBlockEntity.COVER, 8, 27, 64));
addSlot(new BinderSlot(BookBinderBlockEntity.LINK_PANEL, -1000, -1000, 1));
addSlot(new BinderSlot(BookBinderBlockEntity.NOTEBOOK, -1000, -1000, 1));
for (int page = 0; page < BookBinderBlockEntity.PAGE_COUNT; page++) {
addSlot(new BinderSlot(BookBinderBlockEntity.FIRST_PAGE + page, -1000, -1000, 1));
}
addSlot(new ResultSlot(result, 0, 152, 27));
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 selectedPage() { return selectedPage; }
public String title() { return binder == null ? "" : binder.title(); }
public boolean setTitle(Player player, String title) {
if (clientSide || !stillValid(player) || title == null
|| title.length() > BookBinderBlockEntity.MAX_TITLE_LENGTH) return false;
binder.setTitle(title);
refreshResult();
broadcastChanges();
return true;
}
@Override public boolean stillValid(Player player) {
return binder != null && binder.stillValid(player);
}
@Override public boolean clickMenuButton(Player player, int id) {
if (clientSide || !stillValid(player)) return false;
if (id >= STRIP_CLICK_BASE && id <= STRIP_CLICK_BASE + BookBinderBlockEntity.PAGE_COUNT) {
if (!clickStrip(id - STRIP_CLICK_BASE)) return false;
} else if (id >= SELECT_BASE && id < SELECT_BASE + BookBinderBlockEntity.PAGE_COUNT) {
selectedPage = id - SELECT_BASE;
} else if (id == MOVE_PREVIOUS || id == MOVE_NEXT) {
int target = selectedPage + (id == MOVE_PREVIOUS ? -1 : 1);
if (selectedPage < 0 || target < 0 || target >= BookBinderBlockEntity.PAGE_COUNT
|| binder.getItem(BookBinderBlockEntity.FIRST_PAGE + selectedPage).isEmpty()
|| !getCarried().isEmpty()) return false;
int fromSlot = BookBinderBlockEntity.FIRST_PAGE + selectedPage;
int toSlot = BookBinderBlockEntity.FIRST_PAGE + target;
ItemStack from = binder.getItem(fromSlot);
ItemStack to = binder.getItem(toSlot);
binder.setItem(fromSlot, to);
binder.setItem(toSlot, from);
selectedPage = target;
} else if (id == IMPORT_NOTEBOOK) {
if (!binder.importNotebook()) return false;
} else return false;
refreshResult();
broadcastChanges();
return true;
}
private boolean clickStrip(int index) {
ItemStack carried = getCarried();
if (carried.is(ModItems.NOTEBOOK.get())) {
ItemStack emptied = binder.importNotebookFromStack(carried, Math.min(index == 0 ? 0 : index - 1,
binder.nextPageIndex()));
if (emptied.isEmpty()) return false;
setCarried(emptied);
return true;
}
if (!carried.isEmpty()) {
if (index == 0) {
if (!binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty()
|| !binder.canPlaceItem(BookBinderBlockEntity.LINK_PANEL, carried)) return false;
binder.setItem(BookBinderBlockEntity.LINK_PANEL, carried.copyWithCount(1));
} else if (!binder.insertPage(index - 1, carried.copyWithCount(1))) return false;
ItemStack remainder = carried.copy();
remainder.shrink(1);
setCarried(remainder);
return true;
}
ItemStack removed;
if (index == 0) {
removed = binder.removeItem(BookBinderBlockEntity.LINK_PANEL, 1);
} else {
removed = binder.takePage(index - 1);
}
if (removed.isEmpty()) return false;
setCarried(removed);
return true;
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
if (index == RESULT) {
refreshResult();
if (!binder.canAssemble()) return ItemStack.EMPTY;
ItemStack book = binder.previewBook();
ItemStack remainder = book.copy();
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) {
return ItemStack.EMPTY;
}
binder.consumeAssembly();
refreshResult();
return book;
}
Slot sourceSlot = slots.get(index);
if (!sourceSlot.hasItem()) return ItemStack.EMPTY;
ItemStack source = sourceSlot.getItem();
ItemStack original = source.copy();
boolean moved;
if (index < RESULT) {
moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
} else if (source.is(Items.LEATHER) || BookBinderBlockEntity.validCover(source)) {
moved = moveItemStackTo(source, BookBinderBlockEntity.COVER, BookBinderBlockEntity.COVER + 1, false);
} else if (source.is(ModItems.NOTEBOOK.get())) {
ItemStack emptied = binder.importNotebookFromStack(source, binder.nextPageIndex());
if (emptied.isEmpty()) return ItemStack.EMPTY;
sourceSlot.setByPlayer(emptied);
refreshResult();
return original;
} else if (source.is(ModItems.PAGE.get())
&& PageItem.content(source).kind() == PageContent.Kind.LINK_PANEL) {
moved = moveItemStackTo(source, BookBinderBlockEntity.LINK_PANEL,
BookBinderBlockEntity.LINK_PANEL + 1, false);
} else if (source.is(ModItems.PAGE.get())) {
moved = moveItemStackTo(source, BookBinderBlockEntity.FIRST_PAGE, RESULT, false);
} else moved = false;
if (!moved) return ItemStack.EMPTY;
if (source.isEmpty()) sourceSlot.setByPlayer(ItemStack.EMPTY);
else sourceSlot.setChanged();
refreshResult();
return original;
}
@Override public void clicked(int slotId, int button, ClickType clickType, Player player) {
if (!stillValid(player)) {
player.closeContainer();
return;
}
refreshResult();
super.clicked(slotId, button, clickType, player);
}
@Override public void broadcastChanges() {
refreshResult();
super.broadcastChanges();
}
private void refreshResult() {
if (clientSide) return;
ItemStack preview = binder == null ? ItemStack.EMPTY : binder.previewBook();
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
}
private final class BinderSlot extends Slot {
private final int capacity;
private BinderSlot(int index, int x, int y, int capacity) {
super(inputs, index, x, y);
this.capacity = capacity;
}
@Override public boolean mayPlace(ItemStack stack) { return inputs.canPlaceItem(getContainerSlot(), stack); }
@Override public int getMaxStackSize() { return capacity; }
}
private final class ResultSlot extends Slot {
private ResultSlot(Container container, int index, int x, int y) { super(container, index, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) {
return clientSide ? hasItem() : stillValid(player) && binder.canAssemble();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && binder.consumeAssembly()) refreshResult();
super.onTake(player, stack);
}
}
}
@@ -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);
}
}
}
@@ -166,15 +166,12 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
selectedPage = page;
selectedSymbol = null;
} else if (id >= CATALOG_BUTTON_BASE && id < CATALOG_BUTTON_BASE + catalog.size()) {
if (!catalog.equals(WritingDeskCatalog.available(blockEntity))) {
refreshCatalog();
List<SymbolDisplayMetadata> available = WritingDeskCatalog.available(blockEntity);
if (!catalog.equals(available)) {
refreshCatalog(available);
return false;
}
ResourceLocation requested = catalog.get(id - CATALOG_BUTTON_BASE).id();
if (!WritingDeskCatalog.available(blockEntity).stream().anyMatch(entry -> entry.id().equals(requested))) {
return false;
}
selectedSymbol = requested;
selectedSymbol = available.get(id - CATALOG_BUTTON_BASE).id();
} else return false;
refreshOutput();
return true;
@@ -390,7 +387,11 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
private void refreshCatalog() {
if (clientSide || blockEntity == null) return;
List<SymbolDisplayMetadata> current = WritingDeskCatalog.available(blockEntity);
refreshCatalog(WritingDeskCatalog.available(blockEntity));
}
private void refreshCatalog(List<SymbolDisplayMetadata> current) {
if (clientSide || blockEntity == null) return;
if (!current.equals(catalog)) {
catalog = current;
if (serverViewer != null && serverViewer.containerMenu == this) {
@@ -13,8 +13,13 @@ import net.dnorberg.rivencraft.client.ClientWritingDeskCatalog;
import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.registry.ModDataComponents;
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.BlockPos;
import net.minecraft.core.GlobalPos;
@@ -49,7 +54,7 @@ public final class ModNetworking {
}
public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("11");
PayloadRegistrar registrar = event.registrar("13");
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
ModNetworking::requestDescriptiveBookScreen);
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
@@ -71,6 +76,16 @@ public final class ModNetworking {
ClientAgeCelestialAngles::handle);
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC,
ClientWritingDeskCatalog::handle);
registrar.playToServer(SetBookBinderTitlePayload.TYPE, SetBookBinderTitlePayload.STREAM_CODEC,
ModNetworking::setBookBinderTitle);
}
private static void setBookBinderTitle(SetBookBinderTitlePayload payload, IPayloadContext context) {
if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof BookBinderMenu menu
&& menu.containerId == payload.containerId()) {
menu.setTitle(player, payload.title());
}
}
private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) {
@@ -92,10 +107,15 @@ public final class ModNetworking {
// 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.
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(
payload.fromStand(), payload.offhand(),
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) {
@@ -1,7 +1,11 @@
package net.dnorberg.rivencraft.network;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
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.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
@@ -16,7 +20,8 @@ public record OpenDescriptiveBookScreenPayload(
boolean offhand,
GlobalPos source,
ItemStack book,
@Nullable AgeTraits traits
@Nullable AgeTraits traits,
List<SymbolDisplayMetadata> writingSymbols
)
implements CustomPacketPayload {
public static final Type<OpenDescriptiveBookScreenPayload> TYPE = new Type<>(
@@ -40,6 +45,11 @@ public record OpenDescriptiveBookScreenPayload(
buffer.writeFloat(traits.celestialAngle());
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) {
@@ -57,7 +67,16 @@ public record OpenDescriptiveBookScreenPayload(
buffer.readFloat()
)
: 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
@@ -0,0 +1,19 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.ResourceLocation;
public record SetBookBinderTitlePayload(int containerId, String title) implements CustomPacketPayload {
public static final Type<SetBookBinderTitlePayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_book_binder_title"));
public static final StreamCodec<RegistryFriendlyByteBuf, SetBookBinderTitlePayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetBookBinderTitlePayload::containerId,
ByteBufCodecs.STRING_UTF8, SetBookBinderTitlePayload::title,
SetBookBinderTitlePayload::new);
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -2,7 +2,9 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.neoforged.bus.api.IEventBus;
@@ -20,6 +22,12 @@ public final class ModBlockEntities {
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<WritingDeskBlockEntity>> WRITING_DESK =
BLOCK_ENTITIES.register("writing_desk", () -> BlockEntityType.Builder
.of(WritingDeskBlockEntity::new, ModBlocks.WRITING_DESK.get()).build(null));
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<BookBinderBlockEntity>> BOOK_BINDER =
BLOCK_ENTITIES.register("book_binder", () -> BlockEntityType.Builder
.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() {
}
@@ -2,7 +2,9 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.BookBinderBlock;
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.state.BlockBehaviour;
import net.neoforged.bus.api.IEventBus;
@@ -21,6 +23,14 @@ public final class ModBlocks {
"writing_desk", WritingDeskBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
);
public static final DeferredBlock<BookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
"book_binder", BookBinderBlock::new,
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() {
}
@@ -23,6 +23,8 @@ public final class ModCreativeTabs {
output.accept(ModItems.NOTEBOOK.get());
output.accept(ModItems.INK_VIAL.get());
output.accept(ModItems.WRITING_DESK.get());
output.accept(ModItems.INK_MIXER.get());
output.accept(ModItems.BOOK_BINDER.get());
})
.build());
@@ -31,7 +33,12 @@ public final class ModCreativeTabs {
.title(Component.translatable("itemGroup.rivencraft.pages"))
.icon(PageItem::createLinkPanelPage)
.displayItems((parameters, output) -> {
output.accept(ModItems.PAGE.get());
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());
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import com.mojang.serialization.Codec;
import net.minecraft.core.component.DataComponentType;
@@ -61,6 +62,12 @@ public final class ModDataComponents {
.networkSynchronized(NotebookContents.STREAM_CODEC)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<OrderedWriting>> BOOK_WRITING =
DATA_COMPONENTS.register("book_writing", () -> DataComponentType.<OrderedWriting>builder()
.persistent(OrderedWriting.CODEC)
.networkSynchronized(OrderedWriting.STREAM_CODEC)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<Integer>> INK_REMAINING =
DATA_COMPONENTS.register("ink_remaining", () -> DataComponentType.<Integer>builder()
.persistent(Codec.intRange(0, 16))
@@ -41,6 +41,8 @@ public final class ModItems {
new Item.Properties().stacksTo(16)
);
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> INK_MIXER = ITEMS.registerSimpleBlockItem(ModBlocks.INK_MIXER);
private ModItems() {
}
@@ -3,6 +3,8 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.inventory.MenuType;
import net.neoforged.bus.api.IEventBus;
@@ -18,6 +20,10 @@ public final class ModMenus {
MENUS.register("notebook", () -> IMenuTypeExtension.create(NotebookMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<WritingDeskMenu>> WRITING_DESK =
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(WritingDeskMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<BookBinderMenu>> BOOK_BINDER =
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() {
}
@@ -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,43 @@
package net.dnorberg.rivencraft.writing;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.NonNullList;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Shared, server-side vial-to-meter exchange for the Desk and Ink Mixer. */
public final class InkVialExchange {
private InkVialExchange() {}
public static boolean canPay(NonNullList<ItemStack> items, int inkSlot, int bottleSlot,
int storedInk, int cost) {
return storedInk >= cost || canExchange(items, inkSlot, bottleSlot)
&& storedInk + vialInk(items.get(inkSlot)) >= cost;
}
/** Returns added ink; zero means no inputs were changed. */
public static int exchange(NonNullList<ItemStack> items, int inkSlot, int bottleSlot) {
if (!canExchange(items, inkSlot, bottleSlot)) return 0;
ItemStack vial = items.get(inkSlot);
int addedInk = vialInk(vial);
vial.shrink(1);
if (vial.isEmpty()) items.set(inkSlot, ItemStack.EMPTY);
ItemStack bottles = items.get(bottleSlot);
if (bottles.isEmpty()) items.set(bottleSlot, new ItemStack(Items.GLASS_BOTTLE));
else bottles.grow(1);
return addedInk;
}
private static boolean canExchange(NonNullList<ItemStack> items, int inkSlot, int bottleSlot) {
ItemStack vial = items.get(inkSlot);
ItemStack bottles = items.get(bottleSlot);
return vial.is(ModItems.INK_VIAL.get()) && vialInk(vial) > 0
&& (bottles.isEmpty() || bottles.is(Items.GLASS_BOTTLE)
&& bottles.getCount() < bottles.getMaxStackSize());
}
private static int vialInk(ItemStack vial) {
return Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
}
}
@@ -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.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import java.util.ArrayList;
import java.util.List;
@@ -10,30 +11,50 @@ import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** 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 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
? DataResult.success(entries)
: DataResult.error(() -> "Writing exceeds " + MAX_ENTRIES + " entries"))
.xmap(OrderedWriting::new, OrderedWriting::entries);
: DataResult.error(() -> "Writing exceeds " + MAX_ENTRIES + " 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(
OrderedWriting::encode, OrderedWriting::decode);
public OrderedWriting {
if (schemaVersion != CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported writing schema version " + schemaVersion);
}
entries = List.copyOf(Objects.requireNonNull(entries, "entries"));
if (entries.size() > MAX_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) {
ByteBufCodecs.VAR_INT.encode(buffer, writing.schemaVersion);
ByteBufCodecs.VAR_INT.encode(buffer, writing.entries.size());
writing.entries.forEach(entry -> WritingEntry.STREAM_CODEC.encode(buffer, entry));
}
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);
if (size < 0 || size > MAX_ENTRIES) {
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++) {
entries.add(WritingEntry.STREAM_CODEC.decode(buffer));
}
return new OrderedWriting(entries);
return new OrderedWriting(schemaVersion, entries);
}
}
@@ -2,9 +2,9 @@ package net.dnorberg.rivencraft.writing;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
@@ -20,7 +20,7 @@ public final class WritingDeskCatalog {
private WritingDeskCatalog() {}
public static List<SymbolDisplayMetadata> available(WritingDeskBlockEntity desk) {
Map<ResourceLocation, SymbolDisplayMetadata> unique = new TreeMap<>();
Map<ResourceLocation, SymbolDisplayMetadata> unique = new HashMap<>();
for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) {
ItemStack collection = desk.getItem(slot);
if (collection.is(ModItems.PAGE.get())) add(unique, collection);
@@ -0,0 +1,8 @@
{
"variants": {
"facing=north": {"model":"rivencraft:block/book_binder"},
"facing=east": {"model":"rivencraft:block/book_binder","y":90},
"facing=south": {"model":"rivencraft:block/book_binder","y":180},
"facing=west": {"model":"rivencraft:block/book_binder","y":270}
}
}
@@ -0,0 +1,5 @@
{
"variants": {
"": { "model": "rivencraft:block/ink_mixer" }
}
}
@@ -18,8 +18,30 @@
"item.rivencraft.ink_vial": "Ink Vial",
"block.rivencraft.writing_desk": "Writing Desk",
"container.rivencraft.writing_desk": "Writing Desk",
"block.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_hint": "Name the book",
"screen.rivencraft.book_binder.missing_panel": "Link Panel required as the first page",
"screen.rivencraft.book_binder.cover": "Cover · Link Panel · Notebook",
"screen.rivencraft.book_binder.import": "Import Notebook",
"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_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.writing_desk.ink": "Ink: %s/%s",
"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",
"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.",
@@ -49,6 +71,10 @@
"screen.rivencraft.age.name_prompt": "Name this Age before writing the Descriptive Book",
"screen.rivencraft.age.create": "Create",
"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.lighting": "Lighting: %s",
"screen.rivencraft.age.trait.weather": "Weather: %s",
@@ -99,10 +125,18 @@
"generator.rivencraft.void": "Void",
"generator.rivencraft.random_vanilla": "Standard",
"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.exploring": "Exploring",
"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.pages": "Rivencraft Pages"
}
@@ -0,0 +1,61 @@
{
"parent": "minecraft:block/block",
"textures": {
"particle": "rivencraft:block/book_binder",
"body": "rivencraft:block/book_binder",
"press": "rivencraft:block/book_binder_press"
},
"elements": [
{
"from": [0, 0, 0], "to": [16, 10, 16],
"faces": {
"north": {"texture":"#body","uv":[0,4,4,6.5]},
"east": {"texture":"#body","uv":[4,4,8,6.5]},
"south": {"texture":"#body","uv":[8,4,12,6.5]},
"west": {"texture":"#body","uv":[12,4,16,6.5]},
"up": {"texture":"#body","uv":[4,0,8,4]},
"down": {"texture":"#body","uv":[8,0,12,4]}
}
},
{
"from": [0, 10, 1], "to": [3, 14, 15],
"faces": {
"north": {"texture":"#press","uv":[0,4,1.5,6]},
"east": {"texture":"#press","uv":[0,0,7,2]},
"south": {"texture":"#press","uv":[2,4,3.5,6]},
"west": {"texture":"#press","uv":[0,2,7,4]},
"up": {"texture":"#press","uv":[7,0,8.5,6]},
"down": {"texture":"#press","uv":[8.5,0,10,7]}
}
},
{
"from": [13, 10, 1], "to": [16, 14, 15],
"faces": {
"north": {"texture":"#press","uv":[0,4,1.5,6]},
"east": {"texture":"#press","uv":[0,0,7,2]},
"south": {"texture":"#press","uv":[2,4,3.5,6]},
"west": {"texture":"#press","uv":[0,2,7,4]},
"up": {"texture":"#press","uv":[7,0,8.5,6]},
"down": {"texture":"#press","uv":[8.5,0,10,7]}
}
},
{
"from": [3, 11, 3], "to": [13, 12, 4],
"faces": {
"north": {"texture":"#press","uv":[1,15.5,6,16]},
"south": {"texture":"#press","uv":[7,15.5,12,16]},
"up": {"texture":"#press","uv":[0.5,15,5.5,15.5]},
"down": {"texture":"#press","uv":[5.5,15,10.5,15.5]}
}
},
{
"from": [3, 11, 12], "to": [13, 12, 13],
"faces": {
"north": {"texture":"#press","uv":[1,15.5,6,16]},
"south": {"texture":"#press","uv":[7,15.5,12,16]},
"up": {"texture":"#press","uv":[0.5,15,5.5,15.5]},
"down": {"texture":"#press","uv":[5.5,15,10.5,15.5]}
}
}
]
}
@@ -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/book_binder"
}
@@ -0,0 +1,3 @@
{
"parent": "rivencraft:block/ink_mixer"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

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:book_binder"}]}]
}
@@ -0,0 +1,4 @@
{
"type": "minecraft:block",
"pools": [{ "rolls": 1, "entries": [{ "type": "minecraft:item", "name": "rivencraft:ink_mixer" }] }]
}
@@ -0,0 +1,10 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"pattern": ["III", "PPP", "P P"],
"key": {
"I": {"item":"minecraft:iron_ingot"},
"P": {"tag":"minecraft:planks"}
},
"result": {"id":"rivencraft:book_binder","count":1}
}
@@ -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",
"category": "misc",
"ingredients": [
{
"item": "minecraft:book"
},
{
"item": "minecraft:ender_pearl"
}
],
"result": {
"count": 1,
"id": "rivencraft:linking_book"
}
"type": "rivencraft:linking_book",
"category": "misc"
}