Compare commits

...
39 Commits
Author SHA1 Message Date
dnorberg 27abebe3aa Validate ordered Age writing and highlight Binder errors (#30)
Build / build (push) Canceled after 0s
2026-09-30 15:39:53 -04:00
dnorberg 5538ca6aa6 Render Legacy glyph pages and base-25 desk numerals
Build / build (push) Canceled after 0s
2026-09-30 13:20:56 -04:00
dnorberg 3c900634c9 Define server-authoritative symbol semantics (#29)
Build / build (push) Canceled after 0s
2026-09-30 07:17:14 -04:00
dnorberg 8cce41fd71 docs: audit Legacy age grammar and symbol page composition (#28)
Build / build (push) Canceled after 0s
2026-09-30 06:46:45 -04:00
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
dnorberg 6587a924e9 worked on getting writing desk model to match legacy
Build / build (push) Canceled after 0s
2026-09-25 08:27:41 -04:00
dnorberg a359ce7835 Refine Writing Desk collection and ink refill behavior
Build / build (push) Canceled after 0s
2026-09-25 06:07:44 -04:00
dnorberg e2e348d5c5 Refine Writing Desk GUI to match Legacy layout
Build / build (push) Canceled after 0s
2026-09-24 14:54:42 -04:00
dnorberg 7f9cf2a4a5 Fix Writing Desk GUI and two-block rendering
Build / build (push) Canceled after 0s
2026-09-24 14:36:49 -04:00
dnorberg e511e7c10b Add Writing Desk symbol catalog and paper transcription
Build / build (push) Canceled after 0s
2026-09-24 14:08:12 -04:00
dnorberg 05a06a4dc8 added reference images of the writing desk gui
Build / build (push) Canceled after 0s
2026-09-24 05:42:57 -04:00
dnorberg 1dcd3a178e Add Writing Desk paper copying and ink meter
Build / build (push) Canceled after 0s
2026-09-24 05:38:51 -04:00
dnorberg 385741027a Show Writing Desk page previews like Legacy
Build / build (push) Canceled after 0s
2026-09-24 05:17:31 -04:00
dnorberg 972aeaaed6 Add Writing Desk storage and menu foundation
Build / build (push) Canceled after 0s
2026-09-24 05:13:35 -04:00
dnorberg 5066e71897 Match Legacy notebook sparse portrait pages
Build / build (push) Canceled after 0s
2026-09-24 05:02:58 -04:00
dnorberg 02901c9cbc Remove notebook rename flow and add Pages creative tab
Build / build (push) Canceled after 0s
2026-09-24 04:46:51 -04:00
dnorberg d6fd32994f Add writing ink and baseline link panel preparation
Build / build (push) Canceled after 0s
2026-09-24 04:38:05 -04:00
dnorberg 143da0a940 Keep notebook rename keys out of inventory shortcuts
Build / build (push) Canceled after 0s
2026-09-24 04:27:51 -04:00
dnorberg dc5157f973 Fix notebook inventory clicks and ALL button
Build / build (push) Canceled after 0s
2026-09-24 04:23:28 -04:00
dnorberg 956b91c462 Match Legacy notebook page surface
Build / build (push) Canceled after 0s
2026-09-24 04:17:19 -04:00
dnorberg c980366278 Refine notebook screen layout
Build / build (push) Canceled after 0s
2026-09-23 23:28:04 -04:00
dnorberg 616e3d2af5 Add notebook page storage and menu (#19)
Build / build (push) Canceled after 0s
2026-09-23 23:21:09 -04:00
dnorberg 7d335f5952 Bug Fix: Enviromental Audio from Immersive Portals
Build / build (push) Canceled after 0s
2026-09-23 15:32:24 -04:00
dnorberg 211aa0733b Mute Immersive Portals preview world audio
Build / build (push) Canceled after 0s
2026-09-23 15:20:07 -04:00
dnorberg 8b418ce01c Use legacy unbound linking book texture
Build / build (push) Canceled after 0s
2026-09-23 15:07:55 -04:00
dnorberg 62693c2229 Add blank symbol and link-panel pages (#18)
Build / build (push) Canceled after 0s
2026-09-23 14:49:44 -04:00
dnorberg b8d23ee0c0 Define writing and symbol data model (#17)
Build / build (push) Canceled after 0s
2026-09-23 14:24:40 -04:00
dnorberg f276d412b9 Describe your changes
Build / build (push) Canceled after 0s
2026-09-23 06:30:15 -04:00
dnorberg 954dd7097b Complete Milestone 3 documentation (#12)
Build / build (push) Canceled after 0s
2026-09-23 05:51:59 -04:00
dnorberg 1937a8c71d Add Age migration regression coverage (#11)
Build / build (push) Canceled after 0s
2026-09-23 05:41:38 -04:00
dnorberg 24b60706b0 Document and test preview compatibility (#10)
Build / build (push) Canceled after 0s
2026-09-23 02:44:02 -04:00
dnorberg 109acc449a Add data-pack Age runtime profiles (#9)
Build / build (push) Canceled after 0s
2026-09-23 02:15:39 -04:00
dnorberg b06fbda461 Complete Age lifecycle management (#8)
Build / build (push) Canceled after 0s
2026-09-23 00:55:26 -04:00
dnorberg c0bc91858e Organize project tracking on Gitea (#13)
Build / build (push) Canceled after 0s
2026-09-23 00:43:40 -04:00
170 changed files with 9873 additions and 662 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
---
name: Bug report
about: Incorrect behavior or a regression in Rivencraft
title: ""
labels: type/bug
assignees: ""
---
## Observed behavior
Describe what happened, including the dimension, book type, preview mode, and
installed optional mods when relevant.
## Expected behavior
Describe what should have happened.
## Reproduction
1. List the minimum steps needed to reproduce the problem.
2. Include the Rivencraft, NeoForge, and optional-mod versions.
3. Attach the relevant log or crash report.
## Acceptance criteria
- [ ] The reproduction no longer fails.
- [ ] A focused regression check covers the corrected behavior when practical.
+21
View File
@@ -0,0 +1,21 @@
---
name: Implementation task
about: A scoped feature, refactor, compatibility, or documentation task
title: ""
labels: type/feature
assignees: ""
---
## Goal
Describe the behavior or outcome this task should deliver.
## Acceptance criteria
- [ ] Add observable acceptance criteria.
- [ ] Cover persistence, migration, or compatibility when applicable.
- [ ] Record the validation required before closing the issue.
## Context
Link related issues, upstream references, and relevant design constraints.
+125
View File
@@ -0,0 +1,125 @@
Rivencraft Build Instructions
============================
These instructions apply when source code, models, textures, data, or other
resources have been changed.
Requirements
------------
- Java 21 JDK
- A working Gradle wrapper
- The project dependencies available in the Gradle cache, or network access
for Gradle to download them
The project targets Minecraft 1.21.1 and NeoForge 21.1.251.
1. Open the project directory
--------------------------------
cd /path/to/rivencraft
2. Check the changed files
--------------------------
Confirm that model, texture, and other resource files are inside
`src/main/resources`. Java source files belong under `src/main/java`.
For example, block models are normally stored under:
src/main/resources/assets/rivencraft/models/block/
Resource files placed under `src/main/resources` are automatically included in
the finished mod JAR by Gradle.
3. Update the version when appropriate
---------------------------------------
Rivencraft uses the version format `x.y.z.b`:
- `x`: major release
- `y`: milestone completion
- `z`: release slice
- `b`: bug fixes and additional work within that slice
Edit `mod_version` in `gradle.properties` before creating a new release. Do
not reuse a version that has already been published.
4. Build the mod
----------------
From the project directory, run:
./gradlew build --offline
If the required dependencies are not cached locally, remove `--offline`:
./gradlew build
On systems using mise-managed Java and Gradle, use:
mise exec -- ./gradlew build --offline
5. Run the automated tests
--------------------------
To build and run the GameTest suite:
./gradlew build runGameTestServer --offline
Or, with mise:
mise exec -- ./gradlew build runGameTestServer --offline
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
-----------------------------
Build outputs are written to:
build/libs/
The filename uses the configured version, for example:
build/libs/rivencraft-0.3.6.9.jar
To verify that changed resource files are packaged:
unzip -l build/libs/rivencraft-VERSION.jar | rg 'models|textures|data'
Replace `VERSION` with the actual version number.
To create a checksum for distribution:
sha256sum build/libs/rivencraft-VERSION.jar
7. Stop the Gradle daemon when finished
----------------------------------------
This is optional, but useful after a long test run:
./gradlew --stop
Release checklist
-----------------
- Confirm the version was incremented when required.
- Confirm the build completed successfully.
- Confirm all required GameTests passed.
- Confirm changed resources are present in the JAR.
- Generate and record the SHA-256 checksum.
- Test the JAR in the target Minecraft 1.21.1 NeoForge instance.
- Publish the JAR only after client-side testing is complete.
+13
View File
@@ -6,6 +6,19 @@ It is not an official release of Mystcraft.
Mystcraft Legacy source: https://github.com/Mystcraft/Mystcraft-Legacy
Mystcraft Legacy copyright: XCompWiz and contributors
Mystcraft Legacy license: GNU Lesser General Public License version 3
The animated ink-vial item texture in this project is copied from Mystcraft
Legacy's assets/mystcraft/textures/items/ink_vial.png under that license.
The Notebook page atlas is copied from Legacy's assets/mystcraft/gui/bookui_pagel.png
under the same license.
The Writing Desk item texture is copied from Legacy's
assets/mystcraft/textures/items/writingdesk.png under the same license.
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
+115 -8
View File
@@ -4,20 +4,116 @@ 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 and books/linking milestones are complete. The project currently
provides functional Linking Books and randomly seeded, persistent vanilla-like
Ages created from page-free Descriptive Books. Symbols, instability, and the
full writing mechanics have not been ported yet.
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
have not been ported yet.
Milestone 4 now includes stackable blank Pages plus server-created symbol,
unresolved-symbol, and baseline link-panel states. Page visuals follow the
legacy runtime-composition rules. Craft an Ink Vial from two black dye, a glass
bottle, and a water bucket; the bucket is returned. Hold vanilla paper in one
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
the selected page appear on its Legacy-inspired model.
Desks placed before 0.3.6.1 keep their old one-block footprint until broken
and placed again; leave an adjacent space when replacing one.
Its 88 collection tabs, paper input, ink vial input, empty glass-bottle return,
16-unit ink meter, and inventory persist
through world saves; breaking it drops stored contents. The desk screen is a
functional foundation using Legacy's original desk and scrollbar textures.
Four tabs are visible at once; the controls above and below them move by four
(1–4, 5–8, through 85–88). Each viewer can browse a different group. Existing
four-tab desks retain their collections and supplies when upgraded.
With a notebook selected, pick up a Page from your inventory and click an empty
position on the left surface to store one page there. Clear search/filter/sort
and turn off `ALL` to place pages at specific positions; occupied positions are
never overwritten. A notebook tab exposes all 27 notebook positions (scroll to
reach the rest); a loose-page tab exposes only its one occupied position.
Empty tabs have no page storage until a notebook or page is put in the tab slot.
Click a page on the desk surface to select it for copying. Drag a page from one
notebook position to an empty position to move it, or onto another page to swap
their positions. This also works between notebooks shown in the `ALL` view.
Drag a page out of the panel to turn it back into an item. Drop it over a player
inventory slot or desk tab to place it there; a tab with a notebook adds it to
the first open position. Dropping elsewhere leaves the page on the cursor for
normal inventory placement. The source notebook or tab loses the page only after
a valid drop.
Put vanilla paper in the
upper-left slot and Ink Vials in the upper-right; a copy appears in the
lower-left result slot when the selected page is valid. Taking it consumes one
paper and, by default, one ink. An available vial refills an empty ink meter
when inserted or when copying uses its last ink, and returns a vanilla glass
bottle to the lower-right slot. Removing a bottle also retries a blocked refill.
Refilling waits if that slot is full or contains another item. The `AZ` control sorts
visible pages by name; `ALL` shows pages held across every desk tab. Search and
category filtering narrow the view without granting new pages, and cross-tab
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.
@@ -60,6 +156,10 @@ according to the configured Return Link policy.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
Age creation, persistence, lifecycle commands, configuration, backups, and
administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
Data-pack runtime profiles are documented in
[docs/AGE_PROFILES.md](docs/AGE_PROFILES.md).
## Requirements
@@ -77,6 +177,9 @@ view. Without either integration, Rivencraft uses its built-in animated linking
panel. Clients can set `book_preview.mode` in
`rivencraft-client.toml` to `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
Unavailable optional renderers safely fall back to `LEGACY`.
The complete backend matrix, supported combinations, cleanup behavior, and
known upstream limits are documented in
[docs/PREVIEW_COMPATIBILITY.md](docs/PREVIEW_COMPATIBILITY.md).
## Build and run
@@ -86,6 +189,10 @@ Unavailable optional renderers safely fall back to `LEGACY`.
```
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
@@ -107,8 +214,8 @@ Mystcraft Legacy targets Forge for Minecraft 1.12.2. Rivencraft therefore
ports one subsystem at a time onto current registries, codecs, data packs, and
NeoForge APIs instead of importing the legacy source tree wholesale.
See [docs/PORTING.md](docs/PORTING.md) for the initial roadmap and provenance
rules.
See [docs/PORTING.md](docs/PORTING.md) for the roadmap, live Gitea tracking
links, architectural direction, and provenance rules.
## License
+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', 'grammar', '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.

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

+109
View File
@@ -0,0 +1,109 @@
# Age creation and administration
## Creation
A page-free Descriptive Book creates an Age when the player assigns a name and
presses **Create**. Rivencraft immediately assigns a UUID, derives a unique
Minecraft-safe dimension identifier from the requested name, rolls a seed and
traits, and persists that definition before asking Dynamic Dimensions to create
the runtime world. Names that normalize to the same identifier receive stable
suffixes such as `_2` and `_3`.
The selected terrain profile, biome distribution, lighting, weather, celestial
period, celestial angle, and phase are deterministic and become part of the
saved definition. A five-by-five cobblestone platform with clear headroom is
prepared at a safe arrival point. The completed book and every copy carrying
the same UUID resolve to that definition.
Creating an Age can take several ticks. The book cannot be reopened while its
creation request is active. If creation is interrupted after the definition is
saved, using the book again resumes from the same immutable definition instead
of rolling a replacement.
## Persistence
Rivencraft owns each Age definition for the lifetime of a world. A definition
contains the Age UUID, canonical name, seed, generation version, runtime
dimension identifier, spawn, and deterministic traits. Definitions are stored
in the Overworld saved data and are never rerolled during ordinary loading.
Runtime terrain and entity data remain in the normal world dimension storage
managed by Minecraft and Dynamic Dimensions. Back up the complete world folder
while the server is stopped; copying only a dimension folder does not preserve
the authoritative Age registry.
## Lifecycle
An Age definition is created before its runtime dimension. If dimension
creation is interrupted, the saved definition remains authoritative and the
same Age is reconstructed the next time its Descriptive Book is used.
Registered Ages load when the server starts. A book also loads its destination
on demand when an administrator previously unloaded it. Unloading retains the
definition and world folder, so existing books continue to reference the same
Age and later reload the same terrain.
Rivencraft does not automatically delete Ages. Safe permanent deletion needs a
complete reference index for books in inventories, containers, Book Stands,
and unloaded chunks. Until that exists, retaining the definition prevents an
administrator action from silently orphaning books.
Destination-preview chunk loaders are temporary. Rivencraft releases them when
the book closes, the player travels, the player disconnects, the destination
Age is unloaded, or the server stops.
## Commands
The commands require permission level 2.
- `/rivencraft age list` lists every registered Age and whether its runtime
dimension is currently loaded.
- `/rivencraft age unload <dimension>` safely unloads one Age. Players in that
Age are handled by Dynamic Dimensions, and its definition and world data are
retained. Use the command suggestions to select a registered dimension.
Opening a Descriptive Book for an unloaded Age loads it again from its saved
definition.
## Configuration
Rivencraft writes gameplay settings to the world/server configuration managed
by NeoForge. `descriptive_books.return_link_mode` accepts:
- `ON_CREATION` (default): create one Return Link the first time a player
enters each Age.
- `ALWAYS`: create a Return Link after every successful Descriptive Book trip.
- `DISABLED`: never create Return Links automatically.
Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode`
setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes
only presentation and cannot change server-side Age generation or travel
validation. See [PREVIEW_COMPATIBILITY.md](PREVIEW_COMPATIBILITY.md) for the
selection matrix and integration limits.
Runtime generation profiles are server data-pack resources. Their schema,
override rules, reload behavior, and deterministic fallback are documented in
[AGE_PROFILES.md](AGE_PROFILES.md).
## Administrator recovery
Use this sequence when a book cannot load an existing Age:
1. Stop the server and make a copy of the complete world folder.
2. Start the server and run `/rivencraft age list`. Confirm that the expected
canonical name and dimension identifier remain registered.
3. If the Age is listed as unloaded, open its Descriptive Book to reload it.
This keeps the UUID, seed, profile, traits, and existing world folder.
4. If a data-pack profile is missing or invalid, restore or repair that pack,
run `/reload`, then unload and reopen the Age. A removed custom profile uses
the deterministic `rivencraft:standard` fallback and logs the substitution.
5. If the definition is missing, restore the complete world backup. Moving a
dimension folder into another world cannot reconstruct its UUID or books.
6. If the definition exists but its dimension data is damaged, restore the
matching dimension data and authoritative world saved data from the same
backup generation. Do not combine them from different backups.
There is intentionally no delete command in Milestone 3. Do not manually
delete an Age definition or rename its dimension folder: books may exist in
inventories, containers, Book Stands, or unloaded chunks, and would be
orphaned. `/rivencraft age unload` is the supported reversible maintenance
operation.
+50
View File
@@ -0,0 +1,50 @@
# Age runtime profiles
Rivencraft loads Age runtime profiles from server data packs at
`data/<namespace>/rivencraft/age_profiles/<name>.json`. The resulting profile ID
is `<namespace>:<name>`. Profiles select registered world-generation resources;
they do not contain an Age's seed, name, spawn, or rolled traits.
## Schema version 1
```json
{
"schema_version": 1,
"terrain": "noise",
"noise_settings": "minecraft:overworld",
"biome_preset": "minecraft:overworld",
"dimension_type": "minecraft:overworld"
}
```
`terrain` is `noise`, `flat`, or `void`. Noise profiles require
`noise_settings` and `biome_preset` references. Every profile requires a
`dimension_type` reference. Omitted resource references use the Overworld
entry. Rivencraft validates all referenced entries against the loaded dynamic
registries during data-pack reload. An invalid profile is rejected.
The built-in profile IDs are `rivencraft:standard`, `amplified`, `island`,
`cave`, `flat`, and `void`. Their JSON files are ordinary built-in data-pack
resources and can be overridden by a higher-priority pack. The example under
`docs/example-age-profile-pack` can be copied into a world's `datapacks`
directory. Valid custom profiles join the stable, sorted pool used when a new
Age rolls its base terrain; the selected ID is then persisted with that Age.
## Persistence and reload behavior
An Age permanently stores its selected profile ID, generation version, seed,
and rolled traits. `/reload` replaces the profile definitions but does not
reroll those immutable values. An unloaded Age resolves its stored profile
again when it loads, so intentional profile overrides affect its runtime
resources and newly generated chunks.
A profile removed after an Age was created resolves to `rivencraft:standard`.
The legacy `rivencraft:random_vanilla` alias also resolves to `standard`.
Rivencraft logs this fallback. Invalid built-in overrides do not replace the
required built-in definitions; a reload fails if a required built-in profile
is absent after resource resolution.
Only schema version 1 is accepted. A future schema change must add an explicit
migration path before incrementing the version. The saved Age generation
version remains independent of this data-pack schema and is never silently
rewritten.
+31
View File
@@ -0,0 +1,31 @@
# Asset provenance
Rivencraft reuses selected assets from
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) under that
project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| Rivencraft asset | Upstream asset | Upstream revision | Changes |
| --- | --- | --- | --- |
| `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/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/gui/symbolcomponents.png` | `assets/mystcraft/textures/symbolcomponents.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Byte-identical shared 512×512 component sheet for client-side page composition |
| Runtime-composed symbol pages (`SymbolPageTextures`) | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Replaces the six pre-generated symbol PNGs; layers four poem words on the shared page background and caches bounded client textures |
Mystcraft Legacy identifies XCompWiz as its project developer and includes the
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
provides the same license terms.
+42
View File
@@ -0,0 +1,42 @@
# GameTest suites
The default dedicated-server run is a small smoke suite: 19 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 21.
```shell
./gradlew runGameTestServer
```
The remaining 49 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=grammar,binder
```
Available suite names are `linking`, `writing`, `grammar`, `desk`, `binder`, `mixer`, and
`recipe`. Run all 68 Rivencraft tests (70 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.
+58
View File
@@ -0,0 +1,58 @@
# Mystcraft Legacy Age grammar audit (milestone 5, issue #28)
Reference: [Mystcraft-Legacy commit `9bc8ddc`](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae), whose [build properties](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/build.properties) identify version 0.13.7.06. The user-provided local Minecraft 1.12.2 extraction (`mystcraft-1.12.2-0.13.7.06`) has the same version in `mcmod.info`; its `assets/mystcraft/textures/symbolcomponents.png` is byte-identical to the source checkout. That extraction contains compiled classes and assets, but no shipped `.cfg` or `.java` files. Source-linked behavioral claims below use the corresponding GitHub Java source. This is a behavior specification, not a code port. Any later adapted code needs its own copyright/provenance record under [PORTING.md](PORTING.md).
## What Legacy actually does
Legacy's [root rule](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/GrammarRules.java#L62) produces, in order: terrain, biome controller, weather, lighting, optional spawning, suns, moons, starfields, doodads, visuals, small/medium/large features, and effects. Each symbol declares the rule it can satisfy and any preceding modifier tokens it needs in [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java). The written page order therefore matters: modifiers appear before their consumer, while missing grammar branches can be generated. The [grammar tree](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/grammar/GrammarTree.java#L158-L222) connects authored terminals and inserts missing branches; [AgeData](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/agedata/AgeData.java#L230-L258) does this on first link with an Age-seeded RNG. Weighted rule *ranks* choose generated symbols; a `null` rank is not randomly generated, though such a rule can connect authored pages ([GrammarGenerator](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/grammar/GrammarGenerator.java#L28-L112)).
Legacy does **not** reject a book just because it omits terrain, biome, weather, or lighting. It fills the gaps. It also has separate instability accounting for dangling/excess symbols; this audit records those as diagnostics, not as milestone 5 generation effects ([AgeController](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java#L196-L247)).
## Symbol-to-Rivencraft mapping
| Legacy category and symbols | Existing Rivencraft path | Milestone 5 disposition |
| --- | --- | --- |
| `TerrainNormal`, `TerrainAmplified`, `TerrainFlat`, `TerrainVoid`, `TerrainNether`, `TerrainEnd`; terrain and sea block modifiers | `AgeProfile` selects noise/flat/void and registered noise settings; random profiles already persist per Age | Compile supported terrain shapes into profile/resources. Nether/End and material modifiers require explicit compatible data-pack resources; do not claim exact 1.12 terrain parity. |
| `BioConNative`, `Single`, `Tiled`, `Grid`, `Tiny`, `Small`, `Medium`, `Large`, `Huge`, plus biome pages | `AgeTraits.BiomeDistribution` has all nine modes; `AgeDimensionService` has sources for these modes | Map modes, consume authored biome IDs in order, then deterministically fill required minimums. Native uses the profile's modern biome preset, not the old `BiomeProvider`. |
| `LightingNormal`, `Dark`, `Bright` | `AgeTraits.Lighting`, dimension type construction | Compile the choice to a stable dimension type/light behavior. Exact Legacy 1.12 brightness-table rendering is not guaranteed by NeoForge's dimension-type fields. |
| `WeatherNorm`, `Fast`, `Slow`, `Off`, `On`, `Cloudy`, `Rain`, `Snow`, `Storm` | `AgeTraits.Weather` has matching names except Legacy `On` maps to `ALWAYS` | Compile weather semantics separately from biome selection. `Snow` must remain deferred until optional season integration; do not silently substitute another weather mode. |
| Suns, moons, starfields; angle, phase, period, color/gradient/sunset modifiers | One `celestialPeriod`, `celestialAngle`, `celestialPhase` on `AgeTraits` | Compile only the representable single-cycle subset first. Multiple celestial bodies, independent periods, custom stars, gradients and sunsets need a richer definition and renderer, not flattening into one value. |
| Sky/fog/cloud/grass/foliage/water colors, natural variants, horizon, rainbow | Existing sky/dimension visuals and preview | Require an explicit modern client/biome-color design; metadata-only symbol pages must not pretend to change rendering. |
| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile and vanilla data-pack generation | Compile only features for which registered 1.21.1 placed features/structures exist. Defer bespoke structures and polish to milestone 7. |
| `EnvAccel`, explosions, lightning, meteors, scorching; PvP and dummy suppression pages | No equivalent Age-definition effect | Defer environmental hazards/instability effects to milestone 6, PvP rule to a separately scoped server policy; retain authored IDs without fake behavior. |
The Legacy symbol list and page names come from [ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L129-L240) and [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L19-L137). Rivencraft's [catalog](../src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java) currently holds display metadata only; its built-in set is just standard terrain, native biomes, normal weather, normal lighting, normal time, and caves. [OrderedWriting](../src/main/java/net/dnorberg/rivencraft/writing/OrderedWriting.java) preserves page order, but no compiler yet connects it to [AgeTraits](../src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java) or [Age profiles](AGE_PROFILES.md).
## Symbol-page artwork: `symbolcomponents.png`
Legacy does not allocate one image tile to each complete Age symbol. The [default word image source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/word/DrawableWord.java#L24-L75) is `mystcraft:textures/symbolcomponents.png`, a 512×512 RGBA sheet. It has an 8×8 grid of 64×64 component cells. For zero-based component index `n`, source pixels begin at `x = (n % 8) * 64`, `y = (n / 8) * 64`. [GuiUtils.drawComponent](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/gui/GuiUtils.java#L131-L152) uses that mapping for GUI drawing; [PageBuilder.PageSprite.stitchWord](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/render/PageBuilder.java#L287-L331) uses it when composing an item sprite. Cell `0` is the missing-word/question-mark fallback. Each component may be tinted and its alpha layered with the other components of a word.
The complete lookup chain for a unique page is:
1. [WordData.init](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/word/WordData.java#L96-L166) registers a list of component indices for each named word (for example, `Nature` uses `5, 6, 8, 10, 11, 12, 15, 16, 17, 22`). [DrawableWord](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/word/DrawableWord.java#L26-L75) also permits per-component colors and an alternate image source. Unregistered words get a deterministic component list from their lowercase name hash ([DrawableWordManager](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/words/DrawableWordManager.java#L34-L56)).
2. [ModSymbols.registerSymbol](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L105-L116) assigns four poem words to each Age symbol. For example, `LightingNormal` is `Ethereal, Dynamic, Cycle, Balance` ([registration](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L184-L186)). The poem, not the symbol ID, selects the component layers.
3. [PageBuilder.PageSprite](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/render/PageBuilder.java#L232-L279) draws those words above, right, below, and left of the page center, respectively. Their target rectangles on its 160×160 working image are `(48,0)`, `(96,48)`, `(48,96)`, `(0,48)`, each 64×64 ([getPageTarget](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/render/PageBuilder.java#L360-L378)). It scales the composed image to a 128×128 item sprite. [GuiUtils.drawSymbol](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/gui/GuiUtils.java#L74-L96) renders the same four-word arrangement directly in book/workstation GUIs.
For Rivencraft, keep **symbol semantics** (`SymbolReference`/catalog ID) separate from **page artwork** (four ordered word names, their component indices/colors, and the shared sheet). Existing built-in page images are static renditions of this process ([asset provenance](ASSET_PROVENANCE.md)); future symbol pages can compose it at build time or in a client-only renderer. Unknown words and missing symbols need distinct fallbacks, and data-pack-only symbols must remain usable without silently borrowing another symbol's image. Do not copy the Legacy renderer wholesale: its texture-atlas and GUI APIs target 1.12.2.
## Verified numeric defaults and bounds
These values are from code, **not** a supposed shipped `.cfg`: the inspected Legacy repository contains no default configuration file with numeric min/max fields for age grammar. [MystConfig](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/config/MystConfig.java) supplies typed config helpers, while symbol enablement uses a Boolean `<symbol>.enabled` option ([SymbolManager](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/SymbolManager.java#L91-L107)). Do not label Rivencraft's present numeric range as a Legacy config range.
| Parameter | Legacy source value/rule | Implementation consequence |
| --- | --- | --- |
| Period pages | `Zero=0`, `Half=0.5`, `Full=1`, `Double=2` ([ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L194-L201)); repeated period modifiers average pairwise ([SymbolLength](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/modifiers/SymbolLength.java#L25-L37)) | Authored basic values have min 0 and max 2; no arbitrary continuous written range is established. A sun without a period uses `0.8 + 0.4 * nextDouble()`—`[0.8, 1.2)`—before conversion to ticks ([SymbolSunNormal](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolSunNormal.java#L47-L73)). Rivencraft's random nonzero `[0.2, 2.0)` is its own existing policy. |
| Angle pages | North/East/South/West = `0/90/180/270` degrees ([ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L187-L190)); repeated values use shortest-arc averaging ([ModifierUtils](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/symbol/ModifierUtils.java#L54-L73)) | Canonical angle is `[0, 360)`; consumers may seed a random angle if omitted. |
| Phase pages | Nadir/Rising/Zenith/Setting = `0/90/180/270` degrees ([ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L198-L201)); the sun divides by 360 ([SymbolSunNormal](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolSunNormal.java#L65-L73)) | Written phase is `[0, 360)` degrees, represented by Rivencraft as `[0, 1)` turns. |
| Biome count | Grammar requires one page for `Single`, `Tiled`, `Grid`; `Tiny`–`Huge` have two explicit biome slots plus a biome-list branch ([SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L91-L99)). `Single` fills to 1 ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerSingle.java#L22-L37)), `Tiled`/`Grid` to 2 ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerTiled.java#L25-L38)), and `Tiny`–`Huge` to 3 ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerLarge.java#L61-L84)) | These are **minimums**, not maximums. `Native` uses vanilla's provider and has no authored biome-list requirement ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerNative.java#L34-L44)). Preserve additional authored biomes where the chosen controller can accept them. |
| Weather normal | Rain duration/base `12000/12000`, cooldown/base `168000/12000`; thunder duration/base `12000/3600`, cooldown/base `168000/12000` ticks ([SymbolWeatherNormal](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolWeatherNormal.java#L25-L36)) | These are source constants, not configurable min/max bounds. Other weather controllers need individual review before parity claims. |
## Compilation and fallback contract for the next issues
1. A Link-Panel-only book, a legacy page-free book, and any book with no effective supported Age symbols retain the current seed-derived random Age path. Preserve the saved writing untouched.
2. A supported partial sequence is **not** invalid: complete missing required branches deterministically from the Age seed, then compile the result. Authored symbols take precedence over generated fillers. This follows Legacy's grammar tree, rather than requiring a full hand-written list.
3. Unknown/unresolved IDs, unsupported-but-known effects, misplaced modifiers, duplicates, and conflicts must be reported distinctly. Do not silently apply a different effect or discard an authored page. A first compiler may use the existing random Age path for combinations it cannot represent, with an explicit diagnostic; it must never claim such a book was successfully compiled.
4. Grammar diagnostics are not instability. Milestone 5 decides recognition, ordering, completion, and whether a supported combination can compile. Milestone 6 may use dangling/excess/conflict diagnostics for instability effects. Milestone 7 owns bespoke structures and polish.
5. Freeze the completed, effective generation definition with the Age identity before creating chunks. Keep page order and original IDs for display/migration, and keep seeded completion deterministic across reloads. New symbol behavior belongs in versioned server data, not the schema-1 display catalog.
The mapping is intentionally narrower than all Legacy symbols. The exact rule and compiler schema are follow-up issues #29–#31; terrain, biome, environment, and feature implementations are #32–#35. No generation behavior or release version changes in this audit.
+76 -438
View File
@@ -1,454 +1,92 @@
# Porting plan
# Porting roadmap
Mystcraft Legacy contains more than 700 Java source files and targets Minecraft
1.12.2. The Minecraft world-generation, dimension, registry, networking, and
rendering APIs have all changed substantially since that release. The port is
split into independently testable milestones.
Rivencraft ports Mystcraft Legacy from Minecraft 1.12.2 to NeoForge 1.21.1.
The world-generation, dimension, registry, networking, and rendering APIs have
changed substantially, so the work is divided into independently testable
milestones.
## Project tracking
Gitea is the source of truth for project status:
- [Issues](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/issues) contain
scoped implementation tasks, bugs, acceptance criteria, and technical
discussion.
- [Milestones](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestones)
group issues into the porting phases below and show their live completion
state.
- [Releases](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/releases)
contain user-facing changelogs for shipped versions.
New work should have an issue before implementation begins. Commits should
reference the issue number when practical. A milestone closes only after its
acceptance issue and all required implementation issues are complete.
## Milestones
1. **Bootstrap** — NeoForge 1.21.1 build, mod metadata, item registration, and
CI. **Complete.**
2. **Books and linking** — descriptive/linking book data components, lectern-like
interaction, safe teleportation, and return links. **Complete.**
3. **Ages** — dimension definitions backed by data packs, deterministic seeds,
lifecycle management, and persistence. Make the legacy-style animated
linking panel the built-in destination preview. Provide optional
Create/Ponder and Immersive Portals integrations as alternative rotating-
snapshot renderers. The configurable preview modes are `AUTO`,
`IMMERSIVE_PORTALS`, `PONDER`, and `LEGACY`; `AUTO` prefers Immersive
Portals when it is installed, then Create/Ponder, and finally the built-in
legacy animation.
4. **Writing system** — pages, symbols, notebooks, desks, and book assembly.
2. **Books and linking** — Descriptive and Linking Book data, Book Stands,
safe teleportation, return links, book screens, and destination previews.
**Complete.**
3. **Ages** — persistent generated dimensions, deterministic traits,
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, 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 worldgen configuration. Optional integrations with
TerraBlender provide composable biome-region generation, while Serene
Seasons and Ecliptic Seasons allow Age generation to participate in their
respective seasonal systems. True Always Snow weather will be introduced
with these season integrations; until then, its persisted trait uses
biome-dependent precipitation. None of these integrations are required for
Rivencraft's core world generation.
6. **Instability** — deterministic validation and effects with server-safe
behavior.
dimension-type, and world-generation configuration. Optional integrations
may add composable biome regions and seasonal behavior. See the
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md).
6. **Instability** — deterministic validation and server-safe effects.
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
sounds, advancements, compatibility, and migration tests.
## Architectural direction
Age references use UUIDs rather than dimension keys. The server-owned Age
registry maps each stable identity to its immutable definition and runtime
dimension, keeping saved books valid if an Age is migrated or regenerated.
Operational lifecycle and unload behavior are documented in
[AGES.md](AGES.md). The server data-pack schema for runtime generation is
documented in [AGE_PROFILES.md](AGE_PROFILES.md).
The core mod must remain usable without Create, Ponder, or Immersive Portals.
The built-in legacy animation is the dependency-free destination preview.
Optional preview modes may use Create/Ponder snapshots or Immersive Portals
live rendering when their dependencies are installed. The tested selection
matrix and integration boundaries are documented in
[PREVIEW_COMPATIBILITY.md](PREVIEW_COMPATIBILITY.md).
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 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
- New code uses the `net.dnorberg.rivencraft` package.
- Adapted Mystcraft code must retain its upstream copyright notice and be
tracked in this document or a nearby source comment.
- Do not copy generated, binary, or third-party dependency content from the
- Reused legacy art is recorded in [ASSET_PROVENANCE.md](ASSET_PROVENANCE.md).
- Adapted Mystcraft code retains its upstream copyright notice and is tracked
in a nearby source comment or commit.
- Generated, binary, and third-party dependency content is not copied from the
legacy repository.
- Preserve LGPL-3.0-or-later compatibility for the complete distributed mod.
- The complete distributed mod remains compatible with LGPL-3.0-or-later.
## First vertical slice
## Release policy
The first functional target is: create a Descriptive Book, bind it to one
server-managed Age definition, travel to that Age, and use a Linking Book to
return. This exercises persistence, networking, dimensions, and safety before
the symbol grammar is introduced.
### Milestone 2 progress
- [x] Persistent, network-synchronized link-target data component.
- [x] Linking Book binding with dimension, block position, yaw, and pitch.
- [x] Server-authoritative cross-dimension travel with world-border, collision,
and fluid checks.
- [x] Nearby landing search and clear failure messages.
- [x] Descriptive Book identity and Age reference components.
- [x] Temporary vanilla-dimension Descriptive Book travel with return-link creation.
- [x] Lectern-to-Book-Stand conversion, displayed book, activation, removal,
persistence, and drops.
- [x] Book-screen foundation with server-authoritative naming, binding, and
explicit link activation.
- [x] Ponder dependency and isolated destination-preview level boundary.
- [x] Server-provided destination snapshots rendered as rotating Ponder scenes.
- [x] Book Stand floating names synchronized from the stored book stack.
- [x] Open the stored book screen from a Book Stand with server-validated linking.
- [x] Automated in-game tests for binding, persistence, and travel.
The temporary shapeless Linking Book recipe makes the linking subsystem easy
to test. It can move into the writing/bookbinder progression when that system
is implemented.
Age references use UUIDs rather than dimension keys. Milestone 3 will own the
mapping from that stable identity to server data and a runtime dimension. This
keeps saved books valid if an Age is migrated or regenerated.
Vanilla lecterns accept extensible item tags when inserting books, but their
block entity still validates only vanilla writable and written books. To avoid
an invalid lectern state, inserting a Rivencraft book converts the lectern into
a visually compatible Rivencraft Book Stand. Removing the book converts it
back.
Mystcraft Legacy did not impose an item cooldown when linking. Rivencraft's
initial cooldown used Minecraft's item-type cooldown system, which delayed all
Linking Book stacks at once. Version 0.1.3 removes that cooldown to match the
legacy behavior.
Version 0.1.4 moves Linking Book binding and travel behind a book screen. The
client requests an action, while the server revalidates the held book and owns
all changes to its destination and custom name. Descriptive Book Age references
now carry a display name alongside their stable UUID, with backward-compatible
decoding for existing UUID-only data.
Version 0.1.5 ensures the book interaction listener opens screens only for the
logical-client event. This avoids calling the render system from the integrated
server thread when a book is used in singleplayer.
Version 0.1.6 gives the Linking Book screen an open-book layout inspired by the
legacy interface. The destination preview occupies the left page, the book name
and destination details occupy the right page, and the preview itself is the
link action; the separate Link and Done buttons have been removed.
Version 0.1.7 suppresses Minecraft's default full-screen menu blur for the
Linking Book screen. The open-book texture supplies the GUI background while
the world remains sharp behind it.
Version 0.1.8 renders all Linking Book destination details in the same dark ink
color as the book name for consistent contrast against the parchment.
Version 0.1.9 synchronizes the Book Stand's stored stack to clients and renders
its display name as a camera-facing label above the book.
Version 0.1.10 corrects the nameplate transform and matches Minecraft's normal
plus see-through text passes so angled stand geometry cannot hide the label.
Version 0.1.11 opens the stored book interface when a Book Stand is used with
an empty hand. Linking Books travel through the preview panel, and the server
revalidates the stand position, interaction distance, stored item, and target.
Version 0.1.12 replaces the Linking Book screen's convenience text draws with
explicit no-shadow rendering for clearer dark ink on the parchment texture.
Version 0.1.13 requests a bounded, server-validated 9-by-7-by-9 block snapshot
from the held book or nearby Book Stand and renders it as a clipped, rotating
Ponder world section on the Linking Book's left page.
Version 0.1.14 expands the destination snapshot to 9-by-7-by-17 blocks along
the Z axis, rescales the rotating scene to expose that depth, and colors the
panel background with the destination biome's sky color.
Version 0.1.15 adds three dedicated-server GameTests covering Linking Book
component serialization, successful safe travel with restored orientation, and
rejection of fully blocked destinations. The test suite uses a minimal bundled
structure template and runs with `./gradlew runGameTestServer`.
Version 0.1.16 makes destination previews sample a centered 17-by-7-by-17
volume. The Ponder scene uses a scale that keeps its full horizontal diagonal
inside the panel, so the complete snapshot remains available during its orbit.
Version 0.1.17 changes destination previews to a 9-by-128-by-9 volume: a
square horizontal footprint with a vertical slice spanning 64 blocks below and
63 blocks above the recorded linking location.
Version 0.1.18 changes the preview cache to 9-by-64-by-9, spanning 32 blocks
below and 31 above the link point. Its camera is centered on a 9-by-7-by-9
window around the link point so the book retains a readable close-up.
Version 0.1.19 renders the complete 9-by-64-by-9 snapshot in that Ponder
scene while retaining the target-centered camera framing.
Version 0.1.20 uses a cubic 18-by-18-by-18 snapshot. The renderer remaps the
link point within that cube so its existing target-centered camera framing is
unchanged.
Version 0.1.21 treats air and leaf blocks as passable linking destinations;
links no longer require a solid floor beneath the player.
Version 0.2.0 completes Milestone 2. Until Milestone 3 provides generated Age
dimensions, Descriptive Book UUIDs deterministically resolve to vanilla
dimensions and create a bound Return Link to the source on successful travel.
Version 0.2.1 gives Descriptive Books the same open-parchment layout and
left-page link interaction as Linking Books.
Version 0.2.2 adds the same server-provided Ponder destination preview to
Descriptive Books. If no snapshot is available, the left page shows an
animated linking-panel fallback.
Version 0.2.3 requires Descriptive Book destinations to have a sturdy floor,
while Linking Books continue to permit passable floorless destinations.
Version 0.2.4 expands Descriptive Book arrival validation with an outward,
terrain-aware search for an alternate grounded location near an unsafe target.
Version 0.2.5 uses dimension-appropriate preview skies for vanilla Nether and
End destinations instead of their inherited Overworld biome sky tint.
Version 0.2.6 expands destination snapshots to a 32-by-32-by-32 cube while
keeping the existing target-centered Ponder camera position.
Version 0.2.7 persists each Age's first successful visit and creates its
Return Link only for that initial arrival.
Version 0.2.8 makes Return Link creation a server setting: `ON_CREATION`
(default), `ALWAYS`, or `DISABLED`.
Version 0.2.9.0 begins Milestone 3 with a server-owned, world-persistent Age
registry. Each Age UUID resolves exactly once to an immutable random seed,
generation profile, generation-version marker, canonical name, and creation
time. The temporary vanilla-dimension adapter remains in place until runtime
Age dimensions are introduced by a later release slice.
Version 0.2.10.0 makes page-free Descriptive Books playable. Naming an
unwritten book creates a dedicated, persistent vanilla-like Age through the
Dynamic Dimensions API. Each Age reloads with its server, retains its terrain,
and receives a five-by-five cobblestone platform with cleared arrival headroom.
Version 0.2.10.1 improves Age arrival placement. Non-Nether Ages select the
highest sturdy surface near the origin and fall back to a platform at Y=63 when
no suitable natural footing exists.
Version 0.2.10.2 allows leaf blocks to count as proper natural footing when
selecting the highest arrival surface.
Version 0.2.10.3 scans each candidate column downward from the dimension's top
before choosing footing, preventing cave floors from being selected as the Age
arrival surface. Columns covered by fluid remain ineligible.
Version 0.2.10.4 restores Mystcraft Legacy's standard linking sound for both
Linking and Descriptive Books. Successful travel plays the sound at its source
and destination with the legacy volume and pitch variation.
Version 0.2.10.5 derives each new Age's saved dimension folder from its
player-assigned name. Names are converted to safe lowercase resource paths,
and folder conflicts receive `_2`, `_3`, and later numeric suffixes. Existing
UUID-based dimension folders remain unchanged for save compatibility.
Version 0.2.10.6 prevents a Descriptive Book from reopening while its Age is
being generated. Screen opening now waits for the server's current copy of the
held or displayed book, so a delayed interaction opens only the completed Age.
Version 0.2.11.0 adds a dependency-free, legacy-style portal bloom as the
fallback destination preview for Linking and Descriptive Books. Clients with
Create installed retain the rotating Ponder snapshot; clients without Create
use the destination-tinted animation with the original effect's paired noisy
spirals.
Version 0.2.11.1 makes Create genuinely optional. Ponder remains a compile-time
API for the Create integration, while generated mod metadata no longer requires
Ponder and marks Create as an optional client dependency.
Version 0.2.12.0 introduces the first legacy-inspired Age generator profiles.
New page-free Ages deterministically select and persist Standard, Amplified,
Island, Cave, Flat, or Void base terrain. Existing `random_vanilla` Ages remain
mapped to their original Overworld generator for save compatibility. Terrain
alterations such as Skylands, Tendrils, Spheres, and Floating Islands remain
future Age-grammar work layered above these base profiles.
Version 0.2.12.1 displays the persisted terrain generator on the description
side of written Descriptive Books. Newly written books receive the synchronized
profile immediately, while older books are backfilled from the server-owned Age
registry when opened.
Version 0.2.13.0 constructs Standard, Amplified, Cave, and Island terrain from
Minecraft's worldgen registries instead of borrowing generator instances from
the vanilla levels. Noise settings and biome-source parameter lists therefore
honor the server's loaded data packs. Automated coverage also verifies that a
live Age's seed equals its immutable saved definition; Dynamic Dimensions
derives both from the Overworld seed and the Age dimension identifier.
Version 0.2.16.13 gives Cave and Island/Skylands Ages Overworld biomes, sky,
weather, lighting, and day/night behavior while retaining distinct terrain.
Caves remain enclosed. Skylands use Minecraft's uniform floating-island noise
and Overworld-like surfaces without the End's central dragon island.
Version 0.2.16.14 replaces the Explore text control with a compact eye symbol.
Books opened from a Book Stand display the control disabled, preventing
exploration while the book is mounted.
Version 0.2.16.15 restores Immersive Portals chunk loading for snapshots opened
from Book Stands. Snapshot chunk loading is independent from whether the Explore
control is enabled, so mounted books retain live terrain without allowing the
player to enter Explore mode.
Version 0.2.17.0 begins persistent random Age traits. New page-free Ages select
and save a legacy-inspired biome distribution, lighting mode, weather mode, and
celestial period, angle, and phase. The celestial ranges follow Legacy's random
symbol fallbacks: period 0.2–2.0, angle 0–360 degrees, and phase 0–1. Existing
Ages load with normal Legacy defaults instead of being rerolled. This immutable
trait layer is the foundation for applying each choice to modern worldgen.
Version 0.2.18.0 applies each Age's persisted lighting trait to its runtime
dimension. Normal Lighting retains Overworld skylight and ambient brightness;
Bright Lighting uses Legacy's 0.25 ambient-light floor; Dark Lighting disables
dimension skylight while retaining local block light. The latter is the closest
server-safe modern equivalent to Legacy's removed per-dimension light-table
scaling hook.
Version 0.2.18.1 prevents Cave Ages from choosing a spawn on their upper roof.
Their grounded arrival search now begins with the floor below player Y=64 and
works downward. If it finds no suitable footing, the existing floor-Y=63
platform fallback places the player at Y=64.
Version 0.2.19.0 applies persisted weather traits to loaded Ages. Normal uses
Minecraft's normal weather cycle. Fast and Slow use Legacy's original duration
and cooldown ranges at half and double normal speed for Minecraft's combined
rain/thunder weather timer. Off keeps an Age clear;
Always, Cloudy, Rain, and Snow maintain non-thundering precipitation; Storm
maintains rain and thunder. Minecraft still chooses rain versus snow from biome
temperature, so dedicated Snow/Rain biome overrides remain future work.
Version 0.2.20.0 applies persisted celestial timing to each Age. The saved
period controls NeoForge's per-dimension clock rate, with a period of 1 matching
a vanilla 20-minute day, 0.2 running five times faster, and 2 running at half
speed. The saved phase sets the clock only during first-spawn initialization;
later loads continue from saved world time. Celestial angle remains persisted
for a future custom sky renderer because vanilla dimension data cannot rotate
the sun's render plane.
Version 0.2.20.1 restores Legacy's Zero Length celestial modifier as a rarer
random outcome using its effective one-in-seven grammar weight. Zero Length
freezes an Age at its generated phase without changing the global daylight
cycle, while nonzero Ages retain their independently scaled clocks.
Version 0.2.20.2 gives Zero Length Ages a stationary per-dimension clock rate
instead of advancing and correcting their time once per tick. This keeps the
integer day time fixed while preventing the client skybox from visually
bouncing between adjacent celestial positions.
Version 0.2.21.0 begins applying persisted biome-distribution traits. Native
Distribution retains Minecraft's data-pack-aware Overworld climate source.
Single Biome Distribution deterministically selects one registered biome from
the Age seed and uses it throughout compatible noise-based terrain. Flat and
Void profiles retain their profile-specific biome sources. Legacy's scaled,
tiled, and grid layouts require dedicated modern biome sources and remain the
next distribution work.
Version 0.2.21.1 changes Cave Ages from Nether noise settings to Minecraft's
cave noise settings. Caves remain enclosed, but use stone, water, and
Overworld-like surfaces together with the persisted Native or Single biome
distribution. This prevents Nether terrain generation from leaking into newly
generated Cave Age chunks.
Version 0.2.22.0 applies Tiled Biome Distribution using two deterministic
Age-seeded biomes in the legacy diagonal-checkerboard layout. Each square is
16 chunks (256 blocks) wide, matching Mystcraft Legacy's coordinate shift, and
uses Minecraft's serializable checkerboard biome source so the layout remains
stable across server reloads.
Version 0.2.22.1 includes the server-owned Age traits in the validated
Descriptive Book screen response and displays the terrain, biome distribution,
lighting, weather, day length, celestial angle, and celestial phase on the
book's right-hand description page. This works for held books and Book Stands
without copying mutable Age-generation data onto the item.
Version 0.2.23.0 applies Grid Biome Distribution using two deterministic
Age-seeded biomes in Legacy's diagonal checkerboard layout. Each cell is one
16-by-16-block chunk, and the built-in serializable biome source reconstructs
the same layout whenever the Age reloads.
Version 0.2.24.0 applies Tiny Biome Distribution using three deterministic
Age-seeded biomes and softly bounded seeded regions. Tiny begins at Legacy's
smallest 16-block nominal scale; the serializable biome source retains its
seed, selected biomes, and scale across Age reloads.
Version 0.2.25.0 completes the scaled biome distributions. Small, Medium,
Large, and Huge reuse Tiny's seeded, softly bounded regions at nominal widths
of 32, 64, 128, and 256 blocks. Every size selects three deterministic biomes
and serializes the selection, Age seed, and scale with the runtime dimension.
Version 0.2.25.1 applies biome-specific material rules without replacing an
Age's selected terrain profile. End biome columns generate from End stone;
Nether biome columns use Minecraft's Nether surface rules for netherrack,
soul sand, basalt, and nylium instead of inheriting Overworld blocks.
Version 0.2.26.0 applies each Age's persisted celestial angle to the client
sky. The sun, moon, and stars follow a plane rotated by the saved Legacy-style
angle in normal gameplay and Immersive Portals previews, while vanilla
dimensions retain their standard celestial path.
Version 0.2.26.1 rotates the celestial path around the world's vertical axis,
allowing the saved angle to change each Age's sunrise and sunset compass
direction instead of merely tilting the rendered path.
Version 0.2.14.0 restores the legacy Linking and Descriptive Book artwork for
inventory items and gives each book its matching open-book texture when placed
on a Book Stand. Return Linking Books created inside another Age also retain and
display that Age's saved name instead of exposing its dimension identifier.
Version 0.2.14.1 gives every newly bound Linking Book a friendly destination
label. Standard books and Return Links use saved Age names inside Rivencraft
Ages and the familiar Overworld, The Nether, and The End names in vanilla
dimensions. Existing vanilla links receive the same presentation at display
time without requiring item migration.
Version 0.2.15.0 adds the client-side book-preview mode configuration and the
selection policy for `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, and `LEGACY`.
`AUTO` uses Ponder when Create is present and otherwise uses the built-in
legacy animation. Explicit optional modes fall back to `LEGACY` when their
renderer is unavailable.
Version 0.2.16.0 implements the optional Immersive Portals GUI renderer. Its
default view orbits the live destination like the Ponder snapshot. The Explore
control switches to a movable remote camera using movement keys and mouse drag;
right-click resets the camera and restores the passive book GUI. A validated
per-player chunk loader exists only while the screen is open and is released
on close. `AUTO` now prefers Immersive Portals, then Ponder, then `LEGACY`.
Version 0.2.16.1 corrects Immersive Portals detection to use its NeoForge mod
ID, `immersive_portals_core`. Rivencraft now also refreshes Immersive Portals'
integer dimension map whenever saved or newly created Ages are loaded, before
their chunks can be synchronized to a player.
Version 0.2.16.2 routes book travel through Immersive Portals' seamless
teleport API when that mod is installed, preserving its client-world registry
during links between Ages and vanilla dimensions. The Explore control is also
positioned fully below the live snapshot window on both book screens.
Version 0.2.16.3 restores the full window viewport immediately after drawing
an Immersive Portals snapshot. Book titles, destination details, and controls
therefore render in their normal page positions instead of being compressed
inside the snapshot rectangle.
Version 0.2.16.4 clears the Immersive Portals GUI framebuffer between remote
world renders, preventing moving camera frames from accumulating into a sky
smear. The framebuffer now follows Immersive Portals' shared-target lifecycle
so a queued end-of-frame render cannot target a texture destroyed on GUI close.
Version 0.2.16.5 expands live-preview chunk loading to Immersive Portals'
reference eight-chunk radius. Rivencraft prepares the central 3x3 destination
chunks and requests an immediate tracking update when the GUI opens, providing
terrain for the first useful snapshot while the remaining chunks stream in.
Version 0.2.16.6 makes Linking and Descriptive Book screens non-pausing. The
integrated server now continues ticking in single-player while a book is open,
allowing live preview chunks to generate and stream into the snapshot.
Version 0.2.16.7 handles configured movement bindings directly in both book
screens while Explore mode is active. Movement, jump, and sneak now move the
remote camera even though Minecraft suspends normal gameplay key states while
a GUI is open.
Version 0.2.16.8 links Explore mode to real player movement in the current
dimension. Entering Explore anchors the source player and destination camera;
walking, jumping, falling, or being moved shifts the snapshot camera by the
same displacement while the book remains open and normal collision still
applies.
The preview code is separated behind a renderer interface and uses an isolated
Ponder level populated from server-authoritative destination snapshots.
## Version policy
Versions follow `x.y.z.b`:
- `x`: major release point.
- `y`: number of completed milestones.
- `z`: release slice within the active milestone.
- `b`: bug-fix and additional-work iteration within that release slice.
Starting a release slice increments `z` and resets `b` to zero. Completing a
milestone increments `y` and resets both `z` and `b` to zero. The first
milestone-2 slice is therefore `0.1.1.0`; a fix on that slice is `0.1.1.1`;
and milestone 2 completion is `0.2.0.0`.
## Milestone 1 acceptance record
- The project builds on a Java 21 JDK with `./gradlew build`.
- Mod identity and dependencies are declared in generated NeoForge metadata.
- Registrations are isolated by registry type and use deferred registration.
- The Descriptive Book loads with a model, name, tooltip, and creative tab.
- CI builds every pushed commit and pull request.
The version scheme is documented in the project README. Every published
version receives a Gitea release containing its changelog, built mod JAR, and
SHA-256 checksum. Release notes describe user-visible behavior, compatibility
changes, migrations, and validation performed for that version; ongoing task
notes stay in issues.
+77
View File
@@ -0,0 +1,77 @@
# Destination preview compatibility
Rivencraft has three destination preview backends. The selected backend is a
client presentation detail; the server validates travel and Age generation is
independent of every preview integration.
## Selection matrix
Set `book_preview.mode` in `rivencraft-client.toml`.
| Configured mode | Immersive Portals installed | Create/Ponder installed | Selected backend |
| --- | --- | --- | --- |
| `AUTO` | yes | either | Immersive Portals |
| `AUTO` | no | yes | Ponder snapshot |
| `AUTO` | no | no | built-in legacy animation |
| `IMMERSIVE_PORTALS` | yes | either | Immersive Portals |
| `IMMERSIVE_PORTALS` | no | either | built-in legacy animation |
| `PONDER` | either | yes | Ponder snapshot |
| `PONDER` | either | no | built-in legacy animation |
| `LEGACY` | either | either | built-in legacy animation |
Explicit optional modes fall back directly to `LEGACY`. They do not silently
select the other optional integration. GameTests exercise every row and every
dependency-presence permutation.
## Book behavior
Held Linking and Descriptive Books render the selected backend in the preview
panel. Immersive Portals additionally exposes **Explore**: the player remains
at the opening position, mouse look controls the remote camera, and
right-click returns to the standard book view.
Books on a Book Stand use the same preview panel, but Explore is disabled so
the player cannot move away and leave a remote preview session owned by the
stand screen. Closing either screen releases its server chunk loader. Linking,
disconnecting, unloading the destination Age, and stopping the server also
release affected loaders.
The Immersive Portals backend force-loads the initial 3 by 3 destination area
before registering its wider preview chunk loader. The Ponder backend clears
the previous snapshot before applying replacement block data. The live backend
clears its framebuffer on each render and restores the Minecraft viewport
afterward. These rules prevent stale snapshot frames and book UI elements from
being captured in the preview.
## Supported combinations and upstream limits
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.251, Create
6.0.10, and Immersive Portals 6.0.7. Core Age generation and the `LEGACY`
backend run without Create or Immersive Portals; the headless GameTest server
uses that dependency-free path.
Immersive Portals 6.0.7 is incompatible with Sodium 0.8.13 for Minecraft
1.21.1: its Sodium compatibility mixin references the removed
`OcclusionCuller.Visitor` class and crashes while entering a world. Sodium
0.6.13 avoids that specific mixin failure. This compatibility boundary belongs
to Immersive Portals and Sodium, before Rivencraft's preview code runs.
Immersive Portals reports that some Create contraptions can be invisible when
the two mods are installed together. Rivencraft does not replace either mod's
world or contraption renderer, so this remains an upstream rendering limit.
The `PONDER` or `LEGACY` mode is the supported fallback when live rendering is
unstable in a particular graphics or mod configuration.
## Release verification
For preview-related releases:
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.
4. With Immersive Portals, enter and leave Explore, travel between dimensions,
close the screen, and disconnect; confirm destination chunks render and no
preview loader remains active.
5. With Ponder, reopen different destinations and confirm the previous
snapshot does not bleed into the next one.
+44
View File
@@ -0,0 +1,44 @@
# Server-authoritative symbol semantics (schema v2)
Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalog. Put definitions at `data/<namespace>/rivencraft/symbols/<path>.json`; the file ID becomes the stable symbol ID. For example, `data/example/rivencraft/symbols/terrain/highlands.json` defines `example:terrain/highlands`:
```json
{
"schema_version": 2,
"name": "symbol.example.terrain.highlands",
"description": "symbol.example.terrain.highlands.description",
"category": "example:terrain",
"semantics": {
"role": "terrain",
"value": "example:highlands"
},
"poem": "Terrain Form Tradition Flow"
}
```
`name` and `description` are translation keys. `category` is for display and sorting; it does not determine grammar. `semantics.role` and its typed `value` are server-owned. The terrain value above names an Age runtime profile that a later compiler must resolve and validate. A syntactically valid resource ID alone does not guarantee the profile or feature exists.
`poem` is optional, client-safe artwork metadata: exactly four alphabetic words, ordered top, right, bottom, left. The client layers their glyphs from the shared Legacy component sheet at runtime. GUI thumbnails use the full transparent artwork over their page backgrounds; item icons use the same artwork reduced from 16 to 13 pixels across, over the page texture. Unregistered words use a stable generated component combination; when a symbol has no poem, the six original built-in IDs retain their Legacy poems and other IDs produce four stable fallback words. This affects appearance only, never Age generation. Generated images are cached up to 256 entries and discarded on resource reload.
The Writing Desk's 88 collection tabs use Legacy's number components, chiefly the bottom row of `symbolcomponents.png`, rather than word sigils. Legacy's `ModWords.constructNumber` combines four unit marks (1–4) with four five-step marks (5–20) to form 1–24 and uses a separate zero mark. Rivencraft extends these marks positionally in base 25 for tabs 25–88 (for example, 25 is 1 followed by 0). Hovering still reveals the decimal number for accessibility. The former arbitrary-word glyph generator remains useful for rendering text: on page-free Descriptive Books, the generated Age description now appears as a sequence of layered word glyphs instead of Minecraft's enchantment font. The Age name stays readable.
| Role | Value type and allowed values |
| --- | --- |
| `terrain` | Canonical namespaced Age profile ID. |
| `biome_controller` | `native`, `single`, `tiled`, `grid`, `tiny`, `small`, `medium`, `large`, or `huge`. |
| `biome` | Canonical namespaced biome ID. |
| `lighting` | `normal`, `dark`, or `bright`. |
| `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. |
| `celestial` | `normal_time` (the first representable single-cycle behavior). |
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. |
| `angle_modifier` | JSON number in `[0, 360)` degrees. |
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler will convert to turns. |
| `feature` | Canonical namespaced feature-effect ID; future generation mapping must recognize it. |
These are schema bounds, not claims that Legacy shipped numeric config ranges. See the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source values and biome-count rules. Roles outside this table—including multiple suns, gradients, special structures, and hazards—are intentionally unsupported by schema v2. Their meaning requires a later versioned extension, not an unvalidated free-form field.
The server rejects a v2 definition with an unknown top-level or semantics field, missing/incorrectly typed field, unknown role or choice, malformed/non-canonical resource ID, or out-of-range/non-finite number. An invalid definition is logged with its file ID and excluded from the active catalog. Saved pages and books still retain that ID, allowing the player to recover them if the data pack is corrected. A reload atomically replaces the catalog rather than mutating saved writing.
Schema v1 definitions remain readable as **display-only** symbols; existing packs need not be rewritten merely to show or copy pages. A v1 definition cannot add a `semantics` field. To make one eligible for Age grammar parsing, publish a v2 definition with the **same file path and ID**, adding a valid role and value. Do not rename the page ID or reorder a book's `OrderedWriting`. The client receives only `SymbolDisplayMetadata` (ID, translation keys, category, resolved flag); semantic definitions are not serialized to it. The [ordered-writing parser](WRITING_GRAMMAR.md) validates new books, but does **not yet apply** written symbols to Age generation; issue #31 adds compilation.
The six built-in definitions demonstrate terrain, biome-controller, lighting, weather, celestial, and feature roles. A third-party pack may add its own definitions under its namespace. Use an explicit semantic role even when the display category has the same name; the two fields serve different purposes.
+163
View File
@@ -0,0 +1,163 @@
# Writing and symbol data
Milestone 4 stores what a player wrote without deciding what that writing does
to an Age. Milestone 5 owns grammar validation and symbol interaction,
and translation into biome, noise, dimension-type, and other world-generation
configuration. Milestone 6 owns instability effects. See the
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source-backed
Milestone 5 contract.
## Symbol catalog
The authoritative server catalog loads JSON resources from
`data/<namespace>/rivencraft/symbols/<path>.json`. The resource ID is the
symbol ID. For example, `data/rivencraft/rivencraft/symbols/terrain/standard.json`
defines `rivencraft:terrain/standard`.
Schema version 1 contains display metadata only:
```json
{
"schema_version": 1,
"name": "symbol.rivencraft.terrain.standard",
"description": "symbol.rivencraft.terrain.standard.description",
"category": "rivencraft:terrain"
}
```
`name` and `description` are translation keys, not arbitrary formatted text or
executable behavior. `category` is a namespaced organizational identifier.
Milestone 5 adds [server-authoritative schema v2 semantics](SYMBOL_SEMANTICS.md)
with a typed role and bounded value. Existing v1 definitions remain readable
but display-only; updating an ID to v2 never rewrites saved pages or books.
Invalid definitions are logged and excluded by the server. Menus and future
page synchronization must obtain resolved display metadata from the server;
clients never make a symbol valid by naming it.
Rivencraft ships a small catalog covering terrain, biomes, lighting, weather,
celestial behavior, and features. Its v2 semantics prepare for the full
Legacy-inspired grammar; writing still uses random Age fallback until the
parser and compiler are implemented.
## Persistent writing
`SymbolReference` stores only a namespaced symbol ID. `WritingEntry` is either
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
deliberately different from treating an unknown symbol as valid grammar.
## Pages
The single stackable `rivencraft:page` item has a bounded, persistent
`rivencraft:page_content` component. Its explicit states are blank, symbol,
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. 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
composition from its page background and symbol-component sheet. Data-pack
symbols without a bundled resource-pack model use the legacy question-mark
fallback while keeping their real identifier and display metadata.
The Notebook surface displays pages by their persistent slot index instead of
packing occupied pages together. Empty positions through the last placed page
appear darkened. While carrying a Page, all available positions are shown for
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.
The bucket remains after crafting. Each vial holds 16 ink units, stored in a
persistent, network-synchronized component; only vials with identical remaining
ink stack together. When depleted, a vial returns a glass bottle that can be
used to craft another. The animated item texture comes from Mystcraft Legacy.
The server config sets ink costs independently for symbol copying (default one
unit) and baseline link-panel preparation (default two units). To prepare a
panel, hold vanilla paper in one hand and use an Ink Vial in the other. The
server checks the held items and sufficient ink before consuming
anything. A stack of paper yields exactly one Link Panel Page per use;
if the inventory is full, the result is dropped instead of deleted. Symbol
copying is implemented by the Writing Desk: it previews a copy of the selected
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.
+11
View File
@@ -0,0 +1,11 @@
# Ordered Descriptive Book grammar (Milestone 5, issue #30)
`WritingGrammar` interprets the saved `OrderedWriting` on the server using only `SymbolCatalog`'s schema-v2 semantics. Client display metadata, page names, and supplied translations never determine an Age's meaning. The parser is pure: it returns immutable parsed entries and indexed diagnostics without changing a book or its pages.
New Binder writing has at most one Link Panel plus 27 symbol pages. The Link Panel must be first. A Link-Panel-only book, or an older book with no writing component, keeps the existing random Age path. Any authored supported symbol makes the book a partial or complete grammar, even though **0.4.2.0 still generates a random Age**; issue #31 will compile it into a persistent plan before chunk generation.
The supported order follows the representable subset of the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md): terrain; zero or more biome pages; biome controller; weather; lighting; zero or more period, angle, or phase modifiers; one celestial page; then zero or more distinct features. Biome pages precede the controller that consumes them, and celestial modifiers precede the celestial page. Terrain, controller, weather, lighting, and celestial are single-choice categories; identical repeats are duplicates and differing values conflict. Repeated modifiers are intentional. Biome pages may repeat, preserving author order and any future weighting; feature IDs may not repeat.
Missing terrain, controller, weather, lighting, celestial, or the controller's minimum biome count is an **incomplete** warning, not an error: issue #31 will fill those branches deterministically from the Age seed. The minimum is zero for Native, one for Single, two for Tiled/Grid, and three for Tiny–Huge. Native cannot consume authored biome pages; Single accepts only one. Unknown IDs, schema-v1 display-only symbols, wrong ordering, misplaced modifiers, duplicates, conflicts, missing/extra Link Panels, and oversized writing are errors. The Binder disables output and exposes the first error on the result-slot tooltip. When that error belongs to a page, its thumbnail is tinted and outlined red, the strip scrolls to it, and hovering it shows the diagnostic. This maps the parser's compact entry position back through any empty page positions. Rejected inputs remain removable. Binding an unwritten Descriptive Book rechecks its server-owned writing and reports the first error without creating an Age.
This slice does not score instability, synthesize missing symbols, or apply symbol effects. Those responsibilities belong to issues #31–#35 and Milestone 6. Existing bound books are not revalidated or migrated merely because a data pack changes.
@@ -0,0 +1,7 @@
{
"schema_version": 1,
"terrain": "noise",
"noise_settings": "minecraft:amplified",
"biome_preset": "minecraft:overworld",
"dimension_type": "minecraft:overworld"
}
@@ -0,0 +1,6 @@
{
"pack": {
"pack_format": 48,
"description": "Example Rivencraft Age runtime profile"
}
}
+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.2.26.1
mod_version=0.4.2.1
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.
@@ -4,17 +4,23 @@ import com.mojang.logging.LogUtils;
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;
import net.dnorberg.rivencraft.registry.ModBiomeSources;
import net.dnorberg.rivencraft.event.BookStandConversionHandler;
import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
import net.dnorberg.rivencraft.event.AgeLifecycleEvents;
import net.dnorberg.rivencraft.command.RivencraftCommands;
import net.dnorberg.rivencraft.network.ModNetworking;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeWeatherService;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod;
@@ -37,12 +43,19 @@ public final class Rivencraft {
ModBlocks.register(modEventBus);
ModBlockEntities.register(modEventBus);
ModItems.register(modEventBus);
ModMenus.register(modEventBus);
ModRecipeSerializers.register(modEventBus);
ModSounds.register(modEventBus);
ModCreativeTabs.register(modEventBus);
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
NeoForge.EVENT_BUS.addListener(AgeWeatherService::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerLoggedIn);
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerChangedDimension);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
NeoForge.EVENT_BUS.addListener(AgeRuntimeProfiles::register);
NeoForge.EVENT_BUS.addListener(SymbolCatalog::register);
LOGGER.info("Initializing Rivencraft");
}
}
@@ -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,92 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.entity.LivingEntity;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.phys.BlockHitResult;
public final class WritingDeskBlock extends BaseEntityBlock {
public static final MapCodec<WritingDeskBlock> CODEC = simpleCodec(WritingDeskBlock::new);
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
public static final BooleanProperty EXTENSION = BooleanProperty.create("extension");
public WritingDeskBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(EXTENSION, false));
}
@Override public MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING, EXTENSION);
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getHorizontalDirection().getOpposite();
BlockPos extension = context.getClickedPos().relative(facing.getClockWise());
return context.getLevel().getBlockState(extension).canBeReplaced(context)
? defaultBlockState().setValue(FACING, facing) : null;
}
@Override public void setPlacedBy(Level level, BlockPos pos, BlockState state,
@Nullable LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
level.setBlock(pos.relative(state.getValue(FACING).getClockWise()),
state.setValue(EXTENSION, true), 3);
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return state.getValue(EXTENSION) ? null : new WritingDeskBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
BlockPos controller = state.getValue(EXTENSION)
? pos.relative(state.getValue(FACING).getCounterClockWise()) : pos;
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(controller) instanceof WritingDeskBlockEntity desk) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new WritingDeskMenu(id, inventory, desk),
Component.translatable("container.rivencraft.writing_desk")),
buffer -> {
buffer.writeBlockPos(controller);
WritingDeskCatalog.write(buffer, WritingDeskCatalog.available(desk));
});
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
@Override protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState replacement, boolean moving) {
if (!state.is(replacement.getBlock())) {
if (!level.isClientSide && !state.getValue(EXTENSION)
&& level.getBlockEntity(pos) instanceof WritingDeskBlockEntity desk) {
Containers.dropContents(level, pos, desk);
level.updateNeighbourForOutputSignal(pos, this);
}
BlockPos other = pos.relative(state.getValue(FACING).getClockWise(),
state.getValue(EXTENSION) ? -1 : 1);
BlockState otherState = level.getBlockState(other);
if (otherState.is(this) && otherState.getValue(EXTENSION) != state.getValue(EXTENSION)) {
level.setBlock(other, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 35);
}
}
super.onRemove(state, level, pos, replacement, moving);
}
}
@@ -0,0 +1,279 @@
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.WritingGrammar;
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 WritingGrammar.parse(currentWriting()).canCreate();
}
public WritingGrammar.Result writingGrammar() {
return WritingGrammar.parse(currentWriting());
}
/** Map the parser's compact entry index back to the visible strip, including empty page positions. */
public int writingStripIndex(int grammarIndex) {
if (grammarIndex < 0) return -1;
int entryIndex = 0;
if (canPlaceItem(LINK_PANEL, items.get(LINK_PANEL))) {
if (grammarIndex == 0) return 0;
entryIndex++;
}
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
ItemStack page = items.get(slot);
if (!page.isEmpty() && PageItem.content(page).symbol().isPresent()) {
if (entryIndex == grammarIndex) return slot - FIRST_PAGE + 1;
entryIndex++;
}
}
return -1;
}
private OrderedWriting currentWriting() {
List<WritingEntry> entries = new ArrayList<>();
if (canPlaceItem(LINK_PANEL, items.get(LINK_PANEL))) entries.add(WritingEntry.linkPanel());
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
ItemStack page = items.get(slot);
if (!page.isEmpty()) PageItem.content(page).symbol().ifPresent(reference ->
entries.add(WritingEntry.symbol(reference)));
}
return new OrderedWriting(entries);
}
public ItemStack previewBook() {
if (!canAssemble()) return ItemStack.EMPTY;
if (pendingAgeId == null) {
pendingAgeId = UUID.randomUUID();
setChanged();
}
ItemStack book = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
book.set(ModDataComponents.AGE_REFERENCE.get(), new AgeReference(pendingAgeId, title));
book.set(ModDataComponents.BOOK_WRITING.get(), currentWriting());
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);
}
}
@@ -0,0 +1,176 @@
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;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** Collection tabs, paper, ink vials and empty-vial return are owned by the block. */
public final class WritingDeskBlockEntity extends BlockEntity implements Container {
public static final int COLLECTIONS = 88;
public static final int INK = COLLECTIONS;
public static final int RETURN = INK + 1;
public static final int PAPER = INK + 2;
/** Unused by new desks; retained so old saves cannot silently lose slot contents. */
public static final int LEGACY_OUTPUT = INK + 3;
public static final int SIZE = INK + 4;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int activeCollection;
private int inkRemaining;
private ItemStack displayPage = ItemStack.EMPTY;
public WritingDeskBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.WRITING_DESK.get(), pos, state);
}
public int activeCollection() { return activeCollection; }
public int inkRemaining() { return inkRemaining; }
public ItemStack displayPage() { return displayPage; }
public void setDisplayPage(ItemStack page) {
ItemStack next = page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1);
if (!ItemStack.matches(displayPage, next)) {
displayPage = next;
setChanged();
}
}
public void setActiveCollection(int index) {
if (index >= 0 && index < COLLECTIONS && activeCollection != index) {
activeCollection = index;
setChanged();
}
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) {
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == RETURN) refillEmptyInkBar();
}
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == INK || slot == RETURN) refillEmptyInkBar();
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition)
&& level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
if (slot < COLLECTIONS) return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.PAGE.get());
return switch (slot) {
case INK -> stack.is(ModItems.INK_VIAL.get());
case RETURN -> false;
case PAPER -> stack.is(Items.PAPER);
case LEGACY_OUTPUT -> false;
default -> false;
};
}
/** Consume one vial only when the empty meter can return its glass bottle. */
private void refillEmptyInkBar() {
if (level != null && !level.isClientSide && inkRemaining == 0) {
int addedInk = InkVialExchange.exchange(items, INK, RETURN);
if (addedInk > 0) {
inkRemaining = addedInk;
setChanged();
}
}
}
/** The output is virtual; inputs are committed only after a result can be given to a player. */
public boolean canConsumeCopyMaterials() {
ItemStack paper = items.get(PAPER);
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
int cost = WritingInkService.symbolCopyCost();
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
}
public boolean consumeCopyMaterials() {
if (!canConsumeCopyMaterials()) return false;
int cost = WritingInkService.symbolCopyCost();
if (inkRemaining < cost) {
inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
}
inkRemaining -= cost;
removeItem(PAPER, 1);
setChanged();
if (inkRemaining == 0) refillEmptyInkBar();
return true;
}
@Override public void setLevel(Level level) {
super.setLevel(level);
refillEmptyInkBar();
}
@Override public void clearContent() {
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
displayPage = ItemStack.EMPTY;
setChanged();
}
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(tag, items, registries);
// Pre-88-tab desks stored materials in slots 4..7. Move them once;
// never interpret those stacks as newly added collection tabs.
if (tag.getInt("InventoryVersion") < 1) {
for (int i = 0; i < 4; i++) {
items.set(INK + i, items.get(4 + i));
items.set(4 + i, ItemStack.EMPTY);
}
}
activeCollection = Math.clamp(tag.getInt("ActiveCollection"), 0, COLLECTIONS - 1);
inkRemaining = Math.clamp(tag.getInt("InkRemaining"), 0, InkVialItem.CAPACITY);
displayPage = tag.contains("DisplayPage", CompoundTag.TAG_COMPOUND)
? ItemStack.parse(registries, tag.getCompound("DisplayPage")).orElse(ItemStack.EMPTY)
: ItemStack.EMPTY;
refillEmptyInkBar();
}
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, items, registries);
tag.putInt("InventoryVersion", 1);
tag.putInt("ActiveCollection", activeCollection);
tag.putInt("InkRemaining", inkRemaining);
if (!displayPage.isEmpty()) tag.put("DisplayPage", displayPage.save(registries));
}
@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);
}
}
}
@@ -1,9 +1,10 @@
package net.dnorberg.rivencraft.book;
import dev.galacticraft.dynamicdimensions.api.DynamicDimensionRegistry;
import dev.galacticraft.dynamicdimensions.api.PlayerRemover;
import dev.galacticraft.dynamicdimensions.api.event.DimensionRemovedCallback;
import dev.galacticraft.dynamicdimensions.api.event.DynamicDimensionLoadCallback;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.List;
import java.util.Comparator;
import java.util.Collections;
@@ -21,7 +22,6 @@ import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BlockTags;
import net.minecraft.util.valueproviders.UniformInt;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.biome.Biomes;
@@ -30,7 +30,6 @@ import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.CheckerboardColumnBiomeSource;
import net.minecraft.world.level.biome.FixedBiomeSource;
import net.minecraft.world.level.biome.MultiNoiseBiomeSource;
import net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterLists;
import net.dnorberg.rivencraft.world.ScaledBiomeSource;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
@@ -56,13 +55,14 @@ public final class AgeDimensionService {
}
public static void register() {
DimensionRemovedCallback.register((dimension, level) -> synchronizeImmersivePortals(level.getServer()));
DynamicDimensionLoadCallback.register((server, loader) -> {
ServerLevel overworld = server.overworld();
for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) {
ServerLevel level = loader.loadDynamicDimension(
age.dimensionId(),
createGenerator(server, age),
createDimensionType(age)
createDimensionType(server, age)
);
if (level != null) {
applyCelestialClock(level, age);
@@ -104,7 +104,7 @@ public final class AgeDimensionService {
ServerLevel loaded = DynamicDimensionRegistry.from(server).loadDynamicDimension(
definition.dimensionId(),
createGenerator(server, definition),
createDimensionType(definition)
createDimensionType(server, definition)
);
if (loaded == null) {
Rivencraft.LOGGER.error("Failed to load Age {} as {}", definition.id(), definition.dimensionId());
@@ -115,6 +115,27 @@ public final class AgeDimensionService {
return loaded;
}
/**
* Unloads an Age while retaining its saved definition and dimension data.
* A later book access reconstructs the same runtime level from that data.
*/
public static UnloadResult unload(MinecraftServer server, AgeDefinition definition) {
ResourceKey<Level> key = dimensionKey(definition);
if (server.getLevel(key) == null) {
return UnloadResult.ALREADY_UNLOADED;
}
if (ModList.get().isLoaded("immersive_portals_core")) {
ImmersivePortalsChunkLoaders.closeForDimension(key);
}
boolean unloaded = DynamicDimensionRegistry.from(server)
.unloadDynamicDimension(definition.dimensionId(), PlayerRemover.DEFAULT);
if (!unloaded) {
return UnloadResult.FAILED;
}
return UnloadResult.UNLOADED;
}
private static void applyCelestialClock(ServerLevel level, AgeDefinition age) {
if (age.traits().hasStoppedCelestialCycle()) {
// NeoForge rejects an exact zero rate. The smallest positive float
@@ -240,15 +261,15 @@ public final class AgeDimensionService {
}
private static ChunkGenerator createGenerator(MinecraftServer server, AgeDefinition age) {
ResourceLocation profile = age.generationProfile();
if (profile.equals(AgeGenerationProfiles.FLAT)) {
AgeRuntimeProfile profile = AgeRuntimeProfiles.resolve(age.generationProfile());
if (profile.terrain() == AgeRuntimeProfile.Terrain.FLAT) {
return new FlatLevelSource(FlatLevelGeneratorSettings.getDefault(
server.registryAccess().lookupOrThrow(Registries.BIOME),
server.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
));
}
if (profile.equals(AgeGenerationProfiles.VOID)) {
if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
FlatLevelGeneratorSettings settings = new FlatLevelGeneratorSettings(
Optional.empty(),
server.registryAccess().lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.THE_VOID),
@@ -261,22 +282,11 @@ public final class AgeDimensionService {
var biomeParameters = server.registryAccess()
.lookupOrThrow(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST);
var noiseSettings = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS);
ResourceKey<NoiseGeneratorSettings> settingsKey;
if (profile.equals(AgeGenerationProfiles.CAVE)) {
settingsKey = NoiseGeneratorSettings.CAVES;
} else if (profile.equals(AgeGenerationProfiles.ISLAND)) {
settingsKey = NoiseGeneratorSettings.FLOATING_ISLANDS;
} else if (profile.equals(AgeGenerationProfiles.AMPLIFIED)) {
settingsKey = NoiseGeneratorSettings.AMPLIFIED;
} else {
settingsKey = NoiseGeneratorSettings.OVERWORLD;
}
// Cave uses Minecraft's enclosed cave density with stone, water, and
// Overworld-like surfaces while retaining the Age's biome source.
// Island/Skylands uses uniform floating-island density without the
// End's central dragon island. Both use the Overworld environment.
// random_vanilla is retained as a migration alias for existing Ages.
BiomeSource biomeSource = createBiomeSource(server, age, biomeParameters);
ResourceKey<NoiseGeneratorSettings> settingsKey = ResourceKey.create(
Registries.NOISE_SETTINGS, profile.noiseSettings());
ResourceKey<net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterList> biomePreset =
ResourceKey.create(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST, profile.biomePreset());
BiomeSource biomeSource = createBiomeSource(server, age, biomeParameters, biomePreset);
NoiseGeneratorSettings baseSettings = noiseSettings.getOrThrow(settingsKey).value();
return new NoiseBasedChunkGenerator(biomeSource, Holder.direct(withBiomeMaterialRules(baseSettings)));
}
@@ -323,7 +333,8 @@ public final class AgeDimensionService {
private static BiomeSource createBiomeSource(
MinecraftServer server,
AgeDefinition age,
net.minecraft.core.HolderGetter<net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterList> biomeParameters
net.minecraft.core.HolderGetter<net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterList> biomeParameters,
ResourceKey<net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterList> biomePreset
) {
List<net.minecraft.core.Holder.Reference<Biome>> biomes = server.registryAccess()
.lookupOrThrow(Registries.BIOME)
@@ -370,30 +381,39 @@ public final class AgeDimensionService {
);
}
return MultiNoiseBiomeSource.createFromPreset(
biomeParameters.getOrThrow(MultiNoiseBiomeSourceParameterLists.OVERWORLD));
biomeParameters.getOrThrow(biomePreset));
}
private static DimensionType createDimensionType(AgeDefinition age) {
private static DimensionType createDimensionType(MinecraftServer server, AgeDefinition age) {
AgeRuntimeProfile profile = AgeRuntimeProfiles.resolve(age.generationProfile());
DimensionType base = server.registryAccess().lookupOrThrow(Registries.DIMENSION_TYPE)
.getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, profile.dimensionType())).value();
AgeTraits.Lighting lighting = age.traits().lighting();
boolean hasSkyLight = lighting != AgeTraits.Lighting.DARK;
float ambientLight = lighting == AgeTraits.Lighting.BRIGHT ? 0.25F : 0.0F;
return new DimensionType(
OptionalLong.empty(),
base.fixedTime(),
hasSkyLight,
false,
false,
true,
1.0D,
false,
false,
-64,
384,
384,
BlockTags.INFINIBURN_OVERWORLD,
BuiltinDimensionTypes.OVERWORLD_EFFECTS,
base.hasCeiling(),
base.ultraWarm(),
base.natural(),
base.coordinateScale(),
base.bedWorks(),
base.respawnAnchorWorks(),
base.minY(),
base.height(),
base.logicalHeight(),
base.infiniburn(),
base.effectsLocation(),
ambientLight,
new DimensionType.MonsterSettings(false, true, UniformInt.of(0, 7), 0)
base.monsterSettings()
);
}
public enum UnloadResult {
UNLOADED,
ALREADY_UNLOADED,
FAILED
}
}
@@ -33,6 +33,14 @@ public final class AgeGenerationProfiles {
return RANDOM_PROFILES;
}
/** Maps removed legacy aliases to a stable built-in profile. */
public static ResourceLocation migrationFallback(ResourceLocation profile) {
if (profile.equals(AgeDefinition.RANDOM_VANILLA_PROFILE)) {
return STANDARD;
}
return isSupported(profile) ? profile : STANDARD;
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
@@ -0,0 +1,132 @@
package net.dnorberg.rivencraft.book;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Saved-data migration and round-trip acceptance coverage for persistent Ages. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class AgePersistenceGameTests {
private AgePersistenceGameTests() {
}
@GameTest(template = "empty")
public static void everyTraitCombinationRoundTripsWithoutRerolling(GameTestHelper helper) {
ListTag entries = new ListTag();
AgeTraits.BiomeDistribution[] distributions = AgeTraits.BiomeDistribution.values();
AgeTraits.Lighting[] lightingValues = AgeTraits.Lighting.values();
AgeTraits.Weather[] weatherValues = AgeTraits.Weather.values();
for (int index = 0; index < distributions.length; index++) {
UUID id = new UUID(0x524956454E000000L + index, 0x4147450000000000L + index);
AgeTraits traits = new AgeTraits(
distributions[index],
lightingValues[index % lightingValues.length],
weatherValues[index % weatherValues.length],
index == 0 ? AgeTraits.STOPPED_CELESTIAL_PERIOD : 0.25D + index * 0.15D,
index * 37.5F,
index * 0.1F
);
entries.add(entry(
id,
"Persistent " + distributions[index],
9000L + index,
AgeGenerationProfiles.all().get(index % AgeGenerationProfiles.all().size()),
3 + index,
1200L + index,
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/persistent_" + index),
new BlockPos(index, 70 + index, -index),
traits
));
}
CompoundTag root = new CompoundTag();
root.put("Ages", entries);
AgeRegistrySavedData first = AgeRegistrySavedData.load(root, helper.getLevel().registryAccess());
CompoundTag serialized = first.save(new CompoundTag(), helper.getLevel().registryAccess());
AgeRegistrySavedData restored = AgeRegistrySavedData.load(serialized, helper.getLevel().registryAccess());
for (int index = 0; index < distributions.length; index++) {
UUID id = new UUID(0x524956454E000000L + index, 0x4147450000000000L + index);
helper.assertValueEqual(restored.find(id).orElseThrow(), first.find(id).orElseThrow(),
distributions[index] + " and its weather, lighting, celestial, and identity data must round-trip");
}
helper.succeed();
}
@GameTest(template = "empty")
public static void legacyDefinitionReceivesStableDefaults(GameTestHelper helper) {
UUID id = UUID.fromString("4d797374-6372-6166-7400-000000000001");
CompoundTag legacy = new CompoundTag();
legacy.putUUID("Id", id);
legacy.putString("Name", " Legacy Age ");
legacy.putLong("Seed", 8675309L);
legacy.putString("GenerationProfile", AgeDefinition.RANDOM_VANILLA_PROFILE.toString());
legacy.putLong("CreatedGameTime", 42L);
ListTag entries = new ListTag();
entries.add(legacy);
CompoundTag root = new CompoundTag();
root.put("Ages", entries);
AgeRegistrySavedData migrated = AgeRegistrySavedData.load(root, helper.getLevel().registryAccess());
AgeDefinition definition = migrated.find(id).orElseThrow();
helper.assertValueEqual(definition.id(), id, "Migration must retain the Age UUID");
helper.assertValueEqual(definition.name(), "Legacy Age", "Migration must normalize and retain the Age name");
helper.assertValueEqual(definition.seed(), 8675309L, "Migration must retain the Age seed");
helper.assertValueEqual(definition.generationProfile(), AgeDefinition.RANDOM_VANILLA_PROFILE,
"Migration must retain the legacy generation profile for deterministic runtime fallback");
helper.assertValueEqual(definition.generationVersion(), 1,
"Definitions predating generation versions must receive version 1");
helper.assertValueEqual(definition.dimensionId(), AgeDefinition.dimensionId(id),
"Definitions predating named dimensions must retain the UUID-derived dimension");
helper.assertValueEqual(definition.traits(), AgeTraits.LEGACY_DEFAULTS,
"Definitions predating traits must receive stable legacy defaults");
CompoundTag serialized = migrated.save(new CompoundTag(), helper.getLevel().registryAccess());
AgeDefinition reloaded = AgeRegistrySavedData.load(serialized, helper.getLevel().registryAccess())
.find(id).orElseThrow();
helper.assertValueEqual(reloaded, definition, "A migrated definition must not reroll on its next restart");
helper.succeed();
}
private static CompoundTag entry(
UUID id,
String name,
long seed,
ResourceLocation profile,
int version,
long createdTime,
ResourceLocation dimension,
BlockPos spawn,
AgeTraits traits
) {
CompoundTag entry = new CompoundTag();
entry.putUUID("Id", id);
entry.putString("Name", name);
entry.putLong("Seed", seed);
entry.putString("GenerationProfile", profile.toString());
entry.putInt("GenerationVersion", version);
entry.putLong("CreatedGameTime", createdTime);
entry.putString("Dimension", dimension.toString());
entry.putInt("SpawnX", spawn.getX());
entry.putInt("SpawnY", spawn.getY());
entry.putInt("SpawnZ", spawn.getZ());
CompoundTag serializedTraits = new CompoundTag();
serializedTraits.putString("BiomeDistribution", traits.biomeDistribution().name());
serializedTraits.putString("Lighting", traits.lighting().name());
serializedTraits.putString("Weather", traits.weather().name());
serializedTraits.putDouble("CelestialPeriod", traits.celestialPeriod());
serializedTraits.putFloat("CelestialAngle", traits.celestialAngle());
serializedTraits.putFloat("CelestialPhase", traits.celestialPhase());
entry.put("Traits", serializedTraits);
return entry;
}
}
@@ -91,7 +91,7 @@ public final class AgeRegistrySavedData extends SavedData {
reference.id(),
name,
seed,
AgeGenerationProfiles.select(seed),
AgeRuntimeProfiles.select(seed),
AgeDefinition.CURRENT_GENERATION_VERSION,
createdGameTime,
dimensionId,
@@ -103,7 +103,7 @@ public final class AgeRegistrySavedData extends SavedData {
return created;
}
private static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeRegistrySavedData data = new AgeRegistrySavedData();
ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND);
for (int index = 0; index < serializedAges.size(); index++) {
@@ -111,12 +111,18 @@ public final class AgeRegistrySavedData extends SavedData {
if (!entry.hasUUID(ID_TAG)) {
continue;
}
ResourceLocation profile = ResourceLocation.tryParse(entry.getString(PROFILE_TAG));
ResourceLocation profile = entry.contains(PROFILE_TAG, CompoundTag.TAG_STRING)
&& !entry.getString(PROFILE_TAG).isBlank()
? ResourceLocation.tryParse(entry.getString(PROFILE_TAG))
: null;
if (profile == null) {
profile = AgeDefinition.RANDOM_VANILLA_PROFILE;
}
UUID id = entry.getUUID(ID_TAG);
ResourceLocation dimensionId = ResourceLocation.tryParse(entry.getString(DIMENSION_TAG));
ResourceLocation dimensionId = entry.contains(DIMENSION_TAG, CompoundTag.TAG_STRING)
&& !entry.getString(DIMENSION_TAG).isBlank()
? ResourceLocation.tryParse(entry.getString(DIMENSION_TAG))
: null;
if (dimensionId == null) {
dimensionId = AgeDefinition.dimensionId(id);
}
@@ -0,0 +1,20 @@
package net.dnorberg.rivencraft.book;
import net.minecraft.resources.ResourceLocation;
/** Data-pack-defined resources used to construct an Age runtime dimension. */
public record AgeRuntimeProfile(
int schemaVersion,
Terrain terrain,
ResourceLocation noiseSettings,
ResourceLocation biomePreset,
ResourceLocation dimensionType
) {
public static final int CURRENT_SCHEMA_VERSION = 1;
public enum Terrain {
NOISE,
FLAT,
VOID
}
}
@@ -0,0 +1,140 @@
package net.dnorberg.rivencraft.book;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener;
import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterList;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.neoforged.neoforge.event.AddReloadListenerEvent;
/** Loads {@code data/<namespace>/rivencraft/age_profiles/*.json}. */
public final class AgeRuntimeProfiles {
private static final Gson GSON = new GsonBuilder().create();
private static final String DIRECTORY = "rivencraft/age_profiles";
private static final Map<ResourceLocation, AgeRuntimeProfile> PROFILES = new ConcurrentHashMap<>();
private AgeRuntimeProfiles() {
}
public static void register(AddReloadListenerEvent event) {
event.addListener(new Loader(event.getRegistryAccess()));
}
public static AgeRuntimeProfile resolve(ResourceLocation id) {
AgeRuntimeProfile profile = PROFILES.get(id);
if (profile != null) {
return profile;
}
ResourceLocation fallback = AgeGenerationProfiles.migrationFallback(id);
profile = PROFILES.get(fallback);
if (profile == null) {
throw new IllegalStateException("Missing built-in Age runtime profile " + fallback);
}
Rivencraft.LOGGER.warn("Age runtime profile {} is unavailable; using deterministic fallback {}", id, fallback);
return profile;
}
public static Map<ResourceLocation, AgeRuntimeProfile> profiles() {
return Map.copyOf(PROFILES);
}
/** Selects from all valid loaded profiles in stable resource-ID order. */
public static ResourceLocation select(long seed) {
if (PROFILES.isEmpty()) {
return AgeGenerationProfiles.select(seed);
}
java.util.List<ResourceLocation> ids = PROFILES.keySet().stream().sorted().toList();
return ids.get(Math.floorMod(Long.hashCode(seed), ids.size()));
}
static AgeRuntimeProfile parse(ResourceLocation id, JsonElement json, RegistryAccess registries) {
JsonObject object = json.getAsJsonObject();
int schemaVersion = object.has("schema_version") ? object.get("schema_version").getAsInt() : 1;
if (schemaVersion != AgeRuntimeProfile.CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("unsupported schema_version " + schemaVersion);
}
AgeRuntimeProfile.Terrain terrain = AgeRuntimeProfile.Terrain.valueOf(
requiredString(object, "terrain").toUpperCase(java.util.Locale.ROOT));
ResourceLocation noise = location(object, "noise_settings", "minecraft:overworld");
ResourceLocation biome = location(object, "biome_preset", "minecraft:overworld");
ResourceLocation dimension = location(object, "dimension_type", "minecraft:overworld");
if (terrain == AgeRuntimeProfile.Terrain.NOISE) {
requireRegistryEntry(registries, Registries.NOISE_SETTINGS, noise, id, "noise_settings");
requireRegistryEntry(registries, Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST,
biome, id, "biome_preset");
}
requireRegistryEntry(registries, Registries.DIMENSION_TYPE, dimension, id, "dimension_type");
return new AgeRuntimeProfile(schemaVersion, terrain, noise, biome, dimension);
}
private static String requiredString(JsonObject object, String name) {
if (!object.has(name)) {
throw new IllegalArgumentException("missing " + name);
}
return object.get(name).getAsString();
}
private static ResourceLocation location(JsonObject object, String name, String fallback) {
ResourceLocation id = ResourceLocation.tryParse(object.has(name) ? object.get(name).getAsString() : fallback);
if (id == null) {
throw new IllegalArgumentException("invalid " + name);
}
return id;
}
private static <T> void requireRegistryEntry(
RegistryAccess registries,
ResourceKey<? extends net.minecraft.core.Registry<T>> registry,
ResourceLocation entry,
ResourceLocation profile,
String field
) {
if (registries.lookupOrThrow(registry).get(ResourceKey.create(registry, entry)).isEmpty()) {
throw new IllegalArgumentException(profile + " references missing " + field + " " + entry);
}
}
private static final class Loader extends SimpleJsonResourceReloadListener {
private final RegistryAccess registries;
private Loader(RegistryAccess registries) {
super(GSON, DIRECTORY);
this.registries = registries;
}
@Override
protected void apply(Map<ResourceLocation, JsonElement> resources,
ResourceManager resourceManager,
ProfilerFiller profiler) {
Map<ResourceLocation, AgeRuntimeProfile> loaded = new java.util.HashMap<>();
resources.forEach((id, json) -> {
try {
loaded.put(id, parse(id, json, registries));
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Ignoring invalid Age runtime profile {}: {}", id, exception.getMessage());
}
});
for (ResourceLocation required : AgeGenerationProfiles.all()) {
if (!loaded.containsKey(required)) {
throw new IllegalStateException("Required built-in Age runtime profile is missing: " + required);
}
}
PROFILES.clear();
PROFILES.putAll(loaded);
Rivencraft.LOGGER.info("Loaded {} Age runtime profiles", loaded.size());
}
}
}
@@ -76,6 +76,7 @@ public final class LinkingService {
LinkTarget target
) {
if (ModList.get().isLoaded("immersive_portals_core")) {
ImmersivePortalsChunkLoaders.close(player);
ImmersivePortalsChunkLoaders.teleport(
player, destinationLevel, position, target.yaw(), target.pitch());
} else {
@@ -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()));
});
}
}
@@ -2,12 +2,26 @@ package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.renderer.item.ItemProperties;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.neoforged.neoforge.client.event.RegisterClientReloadListenersEvent;
import net.neoforged.neoforge.client.extensions.common.IClientItemExtensions;
import net.neoforged.neoforge.client.extensions.common.RegisterClientExtensionsEvent;
import net.minecraft.client.renderer.BlockEntityWithoutLevelRenderer;
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)
public final class ClientEvents {
@@ -17,10 +31,42 @@ public final class ClientEvents {
@SubscribeEvent
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new);
}
@SubscribeEvent
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
public static void registerPageItemRenderer(RegisterClientExtensionsEvent event) {
event.registerItem(new IClientItemExtensions() {
private final SymbolPageItemRenderer renderer = new SymbolPageItemRenderer();
@Override
public BlockEntityWithoutLevelRenderer getCustomRenderer() {
return renderer;
}
}, ModItems.PAGE.get());
}
@SubscribeEvent
public static void registerPageTextureReload(RegisterClientReloadListenersEvent event) {
event.registerReloadListener((net.minecraft.server.packs.resources.ResourceManagerReloadListener)
manager -> SymbolPageTextures.clear());
}
@SubscribeEvent
public static void clientSetup(FMLClientSetupEvent event) {
event.enqueueWork(() -> ItemProperties.register(
ModItems.LINKING_BOOK.get(),
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "bound"),
(stack, level, entity, seed) -> stack.has(ModDataComponents.LINK_TARGET.get()) ? 1.0F : 0.0F
));
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem);
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
NeoForge.EVENT_BUS.addListener(ClientBookExploreOverlay::onClientTickPre);
@@ -0,0 +1,20 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
import net.minecraft.client.Minecraft;
import net.neoforged.neoforge.network.handling.IPayloadContext;
public final class ClientWritingDeskCatalog {
private ClientWritingDeskCatalog() {}
public static void handle(WritingDeskCatalogPayload payload, IPayloadContext context) {
context.enqueueWork(() -> {
var player = Minecraft.getInstance().player;
if (player != null && player.containerMenu instanceof WritingDeskMenu menu
&& menu.containerId == payload.containerId()) {
menu.setCatalog(payload.symbols());
}
});
}
}
@@ -0,0 +1,47 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.BlockEntityWithoutLevelRenderer;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
/** Flat page model whose symbol artwork is resolved from the stack at render time. */
public final class SymbolPageItemRenderer extends BlockEntityWithoutLevelRenderer {
public SymbolPageItemRenderer() {
super(Minecraft.getInstance().getBlockEntityRenderDispatcher(), Minecraft.getInstance().getEntityModels());
}
@Override
public void renderByItem(ItemStack stack, ItemDisplayContext context, PoseStack pose,
MultiBufferSource buffers, int light, int overlay) {
ResourceLocation texture = switch (PageItem.content(stack).kind()) {
case BLANK -> texture("page");
case LINK_PANEL -> texture("page_link_panel");
case UNRESOLVED_SYMBOL -> texture("page_unresolved");
case SYMBOL -> SymbolPageTextures.forItem(stack);
};
VertexConsumer vertices = buffers.getBuffer(RenderType.entityCutoutNoCull(texture));
var matrix = pose.last().pose();
vertex(vertices, matrix, 0, 0, 0, 1, light, overlay);
vertex(vertices, matrix, 1, 0, 1, 1, light, overlay);
vertex(vertices, matrix, 1, 1, 1, 0, light, overlay);
vertex(vertices, matrix, 0, 1, 0, 0, light, overlay);
}
private static void vertex(VertexConsumer vertices, org.joml.Matrix4f matrix,
float x, float y, float u, float v, int light, int overlay) {
vertices.addVertex(matrix, x, y, 0.5F).setColor(255, 255, 255, 255)
.setUv(u, v).setOverlay(overlay).setLight(light).setNormal(0, 0, 1);
}
private static ResourceLocation texture(String name) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/item/" + name + ".png");
}
}
@@ -0,0 +1,240 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.NativeImage;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.SymbolPoems;
import net.dnorberg.rivencraft.writing.SymbolNumerals;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.world.item.ItemStack;
/** Client-only, bounded cache of Legacy-style page compositions; no per-symbol PNGs. */
public final class SymbolPageTextures {
private static final int SIZE = 128;
private static final int GLYPH = 51;
// A 16 px item icon displays a 13 px symbol: three pixels narrower overall.
private static final int ITEM_SYMBOL_SIZE = 13;
private static final int ICON_SIZE = 16;
private static final int CACHE_LIMIT = 256;
private static final ResourceLocation BASE = texture("item/page");
private static final ResourceLocation COMPONENTS = texture("gui/symbolcomponents");
private static final ResourceLocation FALLBACK = texture("item/page_symbol");
private static final Map<String, ResourceLocation> CACHE = new LinkedHashMap<>(16, .75F, true);
private static final Map<String, int[]> WORD_CACHE = new LinkedHashMap<>(16, .75F, true);
private static NativeImage base;
private static NativeImage components;
private static int nextId;
// WordData from Mystcraft Legacy 0.13.7.06. Other words get a stable generated glyph.
private static final Map<String, int[]> WORDS = Map.ofEntries(
Map.entry("Terrain", new int[] {6,10,12,13,16,19,24,25,27}),
Map.entry("Form", new int[] {4,6,8,9,10,11,13,14,16,23}),
Map.entry("Tradition", new int[] {4,6,7,8,9,14,17,18,20}),
Map.entry("Flow", new int[] {4,7,12,13,17,18}),
Map.entry("Constraint", new int[] {5,6,9,11,13,20,22,23}),
Map.entry("Nature", new int[] {5,6,8,10,11,12,15,16,17,22}),
Map.entry("Sustain", new int[] {6,16,19,20,21,23}),
Map.entry("Ethereal", new int[] {4,6,8,13,14,16,18,21,23}),
Map.entry("Dynamic", new int[] {4,5,6,12,13,17,18,22,23}),
Map.entry("Cycle", new int[] {4,5,7,16,17,18}),
Map.entry("Balance", new int[] {4,6,9,10,16,19}),
Map.entry("Transform", new int[] {4,5,6,8,11,14,18,20,21}),
Map.entry("Time", new int[] {4,5,7,12,13,19,20,21,22,23}),
Map.entry("System", new int[] {10,12,14,15,16,20,21,22}),
Map.entry("Void", new int[] {4,5,6,16,17,18,23})
);
private SymbolPageTextures() {}
/** Transparent full-size artwork for GUI thumbnails drawn over their own page background. */
public static ResourceLocation forPage(ItemStack stack) {
return forPage(stack, false);
}
/** Full page texture with artwork reduced by three pixels at 16 px item-icon scale. */
public static ResourceLocation forItem(ItemStack stack) {
return forPage(stack, true);
}
private static ResourceLocation forPage(ItemStack stack, boolean item) {
PageContent content = PageItem.content(stack);
if (content.kind() == PageContent.Kind.UNRESOLVED_SYMBOL) return texture("item/page_unresolved");
if (content.kind() != PageContent.Kind.SYMBOL) return FALLBACK;
SymbolDisplayMetadata metadata = stack.get(ModDataComponents.PAGE_SYMBOL_DISPLAY.get());
ResourceLocation id = content.symbol().orElseThrow().id();
return forSymbol(id, metadata == null ? "" : metadata.poem(), item);
}
private static ResourceLocation forSymbol(ResourceLocation id, String poem, boolean item) {
if (poem.length() > 96 || !poem.matches("[A-Za-z]+(?: [A-Za-z]+){3}")) poem = "";
if (poem.isEmpty()) poem = SymbolPoems.builtIn(id);
String key = id + "|" + poem + (item ? "|item" : "|thumbnail");
ResourceLocation cached = CACHE.get(key);
if (cached != null) return cached;
try {
loadSources(Minecraft.getInstance().getResourceManager());
NativeImage image = compose(id, poem, item);
ResourceLocation location = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "generated/page_" + nextId++);
Minecraft.getInstance().getTextureManager().register(location, new DynamicTexture(image));
CACHE.put(key, location);
if (CACHE.size() > CACHE_LIMIT) {
String eldest = CACHE.keySet().iterator().next();
Minecraft.getInstance().getTextureManager().release(CACHE.remove(eldest));
}
return location;
} catch (IOException exception) {
Rivencraft.LOGGER.error("Could not compose symbol page {}", id, exception);
return FALLBACK;
}
}
public static void clear() {
var textures = Minecraft.getInstance().getTextureManager();
CACHE.values().forEach(textures::release);
CACHE.clear();
WORD_CACHE.clear();
if (base != null) base.close();
if (components != null) components.close();
base = null;
components = null;
}
private static void loadSources(ResourceManager manager) throws IOException {
if (base != null && components != null) return;
try (InputStream page = manager.getResourceOrThrow(BASE).open();
InputStream sheet = manager.getResourceOrThrow(COMPONENTS).open()) {
base = NativeImage.read(page);
components = NativeImage.read(sheet);
}
}
private static NativeImage compose(ResourceLocation id, String poem, boolean item) {
// GUI variants have no parchment layer, so their untouched pixels must be transparent.
NativeImage image = new NativeImage(SIZE, SIZE, true);
if (item) {
for (int y = 0; y < SIZE; y++) for (int x = 0; x < SIZE; x++) {
image.setPixelRGBA(x, y, base.getPixelRGBA(x * base.getWidth() / SIZE,
y * base.getHeight() / SIZE));
}
}
String[] words = poem.isBlank() ? generatedWords(id) : poem.split(" ");
int[] offsetsX = {38, 77, 38, 0};
int[] offsetsY = {0, 38, 77, 38};
int glyphSize = item ? GLYPH * ITEM_SYMBOL_SIZE / ICON_SIZE : GLYPH;
for (int word = 0; word < 4; word++) {
int originX = item ? scaleForItem(offsetsX[word]) : offsetsX[word];
int originY = item ? scaleForItem(offsetsY[word]) : offsetsY[word];
for (int component : glyphs(words[word])) {
for (int y = 0; y < glyphSize; y++) for (int x = 0; x < glyphSize; x++) {
int pixel = components.getPixelRGBA((component % 8) * 64 + x * 64 / glyphSize,
(component / 8) * 64 + y * 64 / glyphSize);
int alpha = pixel >>> 24;
if (alpha != 0) image.blendPixel(originX + x, originY + y,
(alpha << 24) | 0x001A212B);
}
}
}
return image;
}
private static int scaleForItem(int coordinate) {
return SIZE / 2 + Math.round((coordinate - SIZE / 2F) * ITEM_SYMBOL_SIZE / ICON_SIZE);
}
private static String[] generatedWords(ResourceLocation id) {
String stem = id.toString();
return new String[] {stem + 'a', stem + 'b', stem + 'c', stem + 'd'};
}
private static int[] glyphs(String word) {
int[] known = WORDS.get(word);
if (known != null) return known;
String key = word.toLowerCase(Locale.ROOT);
int[] cached = WORD_CACHE.get(key);
if (cached != null) return cached;
Random random = new Random(key.hashCode());
int[] components = new int[random.nextInt(10) + 3];
for (int index = 0; index < components.length; index++) components[index] = random.nextInt(20) + 4;
WORD_CACHE.put(key, components);
if (WORD_CACHE.size() > CACHE_LIMIT) WORD_CACHE.remove(WORD_CACHE.keySet().iterator().next());
return components;
}
/** Draw a word as a layered Legacy glyph, or a number with Legacy's number components. */
public static void drawWordGlyph(GuiGraphics graphics, String word, int x, int y, int size) {
if (word.matches("[0-9]+")) {
try {
drawNumeral(graphics, Integer.parseInt(word), x, y, size);
return;
} catch (NumberFormatException ignored) {
// Oversized numerals still receive a stable word glyph.
}
}
graphics.setColor(0.13F, 0.10F, 0.08F, 1F);
for (int component : glyphs(word)) drawComponent(graphics, component, x, y, size);
graphics.setColor(1F, 1F, 1F, 1F);
}
/** Legacy's single-number marks; numbers above 24 use positional base 25. */
public static void drawNumeral(GuiGraphics graphics, int number, int x, int y, int size) {
int[] digits = SymbolNumerals.digitsFor(number);
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
graphics.setColor(0.13F, 0.10F, 0.08F, 1F);
for (int digit : digits) {
for (int component : SymbolNumerals.componentsForDigit(digit))
drawComponent(graphics, component, x, y + (size - digitSize) / 2, digitSize);
x += digitSize + 1;
}
graphics.setColor(1F, 1F, 1F, 1F);
}
/** Render translated prose as a sequence of word glyphs within a fixed-width book page. */
public static int drawGlyphText(GuiGraphics graphics, String text, int x, int y, int maxWidth) {
int cursor = x;
boolean drew = false;
for (String word : text.split("[^\\p{L}\\p{N}]+")) {
if (word.isEmpty()) continue;
int width = word.matches("[0-9]+") ? numeralWidth(word, 12) : 12;
if (cursor != x && cursor + width > x + maxWidth) {
cursor = x;
y += 13;
}
drawWordGlyph(graphics, word, cursor, y, 12);
cursor += width + 3;
drew = true;
}
return y + (drew ? 13 : 0);
}
private static int numeralWidth(String word, int size) {
try {
int count = SymbolNumerals.digitsFor(Integer.parseInt(word)).length;
return count == 1 ? size : count * (size * 3 / 4 + 1) - 1;
} catch (NumberFormatException ignored) {
return size;
}
}
private static void drawComponent(GuiGraphics graphics, int component, int x, int y, int size) {
graphics.blit(COMPONENTS, x, y, size, size,
(component % 8) * 64, (component / 8) * 64, 64, 64, 512, 512);
}
private static ResourceLocation texture(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
}
}
@@ -0,0 +1,55 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Visible selected page and loaded paper, as on the Legacy desk. */
public final class WritingDeskRenderer implements BlockEntityRenderer<WritingDeskBlockEntity> {
public WritingDeskRenderer(BlockEntityRendererProvider.Context context) {}
@Override public void render(WritingDeskBlockEntity desk, float partialTick, PoseStack pose,
MultiBufferSource buffers, int light, int overlay) {
int paper = desk.getItem(WritingDeskBlockEntity.PAPER).getCount();
ItemStack page = desk.displayPage();
if (paper == 0 && page.isEmpty()) return;
pose.pushPose();
pose.translate(0.5, 0, 0.5);
pose.mulPose(Axis.YP.rotationDegrees(
180.0F - desk.getBlockState().getValue(WritingDeskBlock.FACING).toYRot()));
if (paper > 0) {
ItemStack sheet = new ItemStack(Items.PAPER);
int layers = Math.min(4, 1 + paper / 16);
for (int i = 0; i < layers; i++) {
drawFlat(desk, sheet, pose, buffers, light, overlay, 1.22, 0.27 + i * 0.008, -0.14, 0.55F);
}
}
if (!page.isEmpty()) {
drawFlat(desk, page, pose, buffers, light, overlay, 1.0, 0.572, 0.0, 0.8F);
}
pose.popPose();
}
private static void drawFlat(WritingDeskBlockEntity desk, ItemStack stack, PoseStack pose,
MultiBufferSource buffers, int light, int overlay,
double x, double y, double z, float scale) {
pose.pushPose();
pose.translate(x, y, z);
pose.mulPose(Axis.XP.rotationDegrees(90));
pose.scale(scale, scale, scale);
Minecraft.getInstance().getItemRenderer().renderStatic(stack, ItemDisplayContext.FIXED,
light, overlay, pose, buffers, desk.getLevel(), 0);
pose.popPose();
}
@Override public boolean shouldRenderOffScreen(WritingDeskBlockEntity blockEntity) { return true; }
@Override public int getViewDistance() { return 64; }
}
@@ -1,44 +1,22 @@
package net.dnorberg.rivencraft.client.preview;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.compat.PreviewBackendSelector;
import net.neoforged.fml.ModList;
/** Selects the configured destination preview while keeping optional integrations optional. */
public final class DestinationPreviewRenderers {
private enum Backend {
IMMERSIVE_PORTALS,
PONDER,
LEGACY
}
private DestinationPreviewRenderers() {
}
public static DestinationPreviewRenderer create() {
boolean ponderAvailable = ModList.get().isLoaded("create");
// The mode and priority are established before the optional IP renderer
// lands, so selecting it currently follows the documented safe fallback.
boolean immersivePortalsAvailable = ModList.get().isLoaded("immersive_portals_core");
return switch (selectBackend(
return switch (PreviewBackendSelector.select(
RivencraftClientConfig.previewMode(), immersivePortalsAvailable, ponderAvailable)) {
case IMMERSIVE_PORTALS -> new ImmersivePortalsDestinationPreviewRenderer();
case PONDER -> new PonderDestinationPreviewRenderer();
case LEGACY -> new LegacyDestinationPreviewRenderer();
};
}
private static Backend selectBackend(
RivencraftClientConfig.PreviewMode mode,
boolean immersivePortalsAvailable,
boolean ponderAvailable
) {
return switch (mode) {
case AUTO -> immersivePortalsAvailable
? Backend.IMMERSIVE_PORTALS
: ponderAvailable ? Backend.PONDER : Backend.LEGACY;
case IMMERSIVE_PORTALS -> immersivePortalsAvailable ? Backend.IMMERSIVE_PORTALS : Backend.LEGACY;
case PONDER -> ponderAvailable ? Backend.PONDER : Backend.LEGACY;
case LEGACY -> Backend.LEGACY;
};
}
}
@@ -42,7 +42,11 @@ public final class ImmersivePortalsDestinationPreviewRenderer implements Destina
@Override
public void accept(LinkPreviewPayload payload) {
if (destination != null) {
ImmersivePortalsPreviewAudioGuard.clear(destination.dimension());
}
destination = payload;
ImmersivePortalsPreviewAudioGuard.mute(payload.dimension());
resetView();
}
@@ -118,6 +122,7 @@ public final class ImmersivePortalsDestinationPreviewRenderer implements Destina
FRAMEBUFFER.clear(true);
minecraft.getMainRenderTarget().bindWrite(true);
MyRenderHelper.restoreViewPort();
ImmersivePortalsPreviewAudioGuard.markCameraRestorePending();
GuiPortalRendering.submitNextFrameRendering(info, FRAMEBUFFER);
hasCompletedFrame = true;
graphics.renderOutline(left, top, width, height, exploring ? 0xFFE0C36A : 0xFF7B6AA8);
@@ -234,5 +239,8 @@ public final class ImmersivePortalsDestinationPreviewRenderer implements Destina
public void close() {
clearMovement();
hasCompletedFrame = false;
if (destination != null) {
ImmersivePortalsPreviewAudioGuard.clear(destination.dimension());
}
}
}
@@ -0,0 +1,62 @@
package net.dnorberg.rivencraft.client.preview;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Camera;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
/** Prevents an Immersive Portals preview world from contributing sounds to a book screen. */
public final class ImmersivePortalsPreviewAudioGuard {
private static ResourceKey<Level> mutedDimension;
private static boolean cameraRestorePending;
private ImmersivePortalsPreviewAudioGuard() {
}
public static void mute(ResourceKey<Level> dimension) {
mutedDimension = dimension;
}
public static void clear(ResourceKey<Level> dimension) {
if (dimension.equals(mutedDimension)) {
mutedDimension = null;
}
}
public static boolean shouldMute(ClientLevel level) {
Minecraft minecraft = Minecraft.getInstance();
return mutedDimension != null
&& level.dimension().equals(mutedDimension)
&& (minecraft.player == null || level != minecraft.player.level());
}
public static void markCameraRestorePending() {
cameraRestorePending = true;
}
/**
* Immersive Portals' GUI renderer leaves the shared main camera in the
* remote world after its end-of-frame render. Restore it before the next
* frame uses that camera for rain effects and the sound listener.
*/
public static void restorePlayerCamera() {
Minecraft minecraft = Minecraft.getInstance();
if (!cameraRestorePending) {
return;
}
cameraRestorePending = false;
if (minecraft.level == null || minecraft.getCameraEntity() == null) {
return;
}
Camera camera = minecraft.gameRenderer.getMainCamera();
camera.setup(
minecraft.level,
minecraft.getCameraEntity(),
!minecraft.options.getCameraType().isFirstPerson(),
minecraft.options.getCameraType().isMirrored(),
camera.getPartialTickTime()
);
}
}
@@ -0,0 +1,258 @@
package net.dnorberg.rivencraft.client.screen;
import java.util.Locale;
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 int lastGrammarErrorStripIndex = -1;
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());
int errorStripIndex = menu.grammarErrorStripIndex();
if (errorStripIndex != lastGrammarErrorStripIndex) {
if (errorStripIndex >= 0
&& (errorStripIndex < firstVisible || errorStripIndex >= firstVisible + VISIBLE_PAGES))
firstVisible = Math.clamp(errorStripIndex - VISIBLE_PAGES + 1, 0, maxFirstVisible());
lastGrammarErrorStripIndex = errorStripIndex;
}
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);
Component grammarTooltip = menu.grammarError().map(code -> Component.translatable(
"screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT),
errorStripIndex + 1)).orElse(null);
if (menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty()
&& mouseX >= leftPos + 152 && mouseX < leftPos + 168
&& mouseY >= topPos + 27 && mouseY < topPos + 43
&& grammarTooltip != null) {
graphics.renderTooltip(font, grammarTooltip, mouseX, mouseY);
return;
}
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 == errorStripIndex && grammarTooltip != null)
graphics.renderTooltip(font, grammarTooltip, mouseX, mouseY);
else 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, 0, 0, 128, 128, 128, 128);
}
if (index == menu.grammarErrorStripIndex() && menu.grammarError().isPresent()) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0x55FF2020);
graphics.renderOutline(x, y, PAGE_WIDTH, PAGE_HEIGHT, 0xFFFF3030);
}
}
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,8 +1,11 @@
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;
import net.dnorberg.rivencraft.client.SymbolPageTextures;
import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload;
import net.dnorberg.rivencraft.network.BindDescriptiveBookPayload;
import net.dnorberg.rivencraft.network.ModNetworking;
@@ -10,62 +13,77 @@ 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 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 = drawDescription(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
@@ -316,8 +415,14 @@ 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;
return drawDescription(graphics,
Component.translatable("screen.rivencraft.age.trait." + key, value), x, y, 110) + 2;
}
private int drawDescription(GuiGraphics graphics, Component text, int x, int y, int maxWidth) {
return pageFree && age != null
? SymbolPageTextures.drawGlyphText(graphics, text.getString(), x, y, maxWidth)
: drawWrappedWithoutShadow(graphics, text, x, y, maxWidth);
}
private static Component traitName(String group, Enum<?> value) {
@@ -326,8 +431,12 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
}
private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth) {
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) {
for (FormattedCharSequence line : font.split(text, maxWidth)) {
graphics.drawString(font, line, x, y, BOOK_TEXT_COLOR, false);
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
@@ -0,0 +1,303 @@
package net.dnorberg.rivencraft.client.screen;
import java.util.Locale;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.client.SymbolPageTextures;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.registry.ModItems;
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.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.ClickType;
import net.minecraft.world.item.ItemStack;
/** A page surface modelled on Legacy's folder GUI, backed by the Notebook menu. */
public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu> {
private static final int SURFACE_TOP = 20;
private static final int SURFACE_BOTTOM = 132;
private static final int SURFACE_RIGHT = 156;
private static final int PAGE_WIDTH = 30;
private static final int PAGE_HEIGHT = 40;
private static final int PAGE_STEP_X = 31;
private static final int PAGE_STEP_Y = 41;
private static final int PAGE_COLUMNS = 5;
private static final ResourceLocation PAGE_ATLAS = texture("gui/notebook_pages");
private EditBox textInput;
private boolean draggingScrollbar;
private boolean surfaceClickPendingRelease;
private int selectedSlot = -1;
private int scroll;
public NotebookScreen(NotebookMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
imageWidth = 176;
imageHeight = 213;
inventoryLabelY = 123;
}
@Override public boolean isPauseScreen() { return false; }
@Override
protected void init() {
super.init();
Button sortButton = Button.builder(Component.literal("AZ"), button -> {})
.bounds(leftPos, topPos, 18, 18).build();
Button allButton = Button.builder(Component.literal("ALL"), button -> {})
.bounds(leftPos + 20, topPos, 26, 18).build();
sortButton.active = false;
allButton.active = false;
addRenderableWidget(sortButton);
addRenderableWidget(allButton);
textInput = new EditBox(font, leftPos + 48, topPos, 128, 18,
Component.translatable("screen.rivencraft.notebook.search"));
textInput.setMaxLength(32);
textInput.setHint(Component.translatable("screen.rivencraft.notebook.search"));
textInput.setResponder(value -> scroll = 0);
addRenderableWidget(textInput);
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
int pageSlots = displayedPageSlots();
scroll = Math.min(scroll, maxScroll(pageSlots));
graphics.enableScissor(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
topPos + SURFACE_BOTTOM);
for (int index = 0; index < pageSlots; index++) {
int x = leftPos + (index % PAGE_COLUMNS) * PAGE_STEP_X;
int y = topPos + SURFACE_TOP + (index / PAGE_COLUMNS) * PAGE_STEP_Y - scroll;
if (y + PAGE_HEIGHT > topPos + SURFACE_TOP && y < topPos + SURFACE_BOTTOM) {
drawPage(graphics, index, x, y);
}
}
graphics.disableScissor();
drawScrollbar(graphics, pageSlots);
int hovered = pageAt(mouseX, mouseY);
if (!menu.getCarried().isEmpty() && inSurface(mouseX, mouseY)) {
graphics.renderItem(menu.getCarried(), mouseX - 8, mouseY - 8);
} else if (hovered >= 0 && !menu.getSlot(hovered).getItem().isEmpty()) {
ItemStack page = menu.getSlot(hovered).getItem();
if (matchesSearch(page)) graphics.renderTooltip(font, page, mouseX, mouseY);
} else {
renderTooltip(graphics, mouseX, mouseY);
}
}
@Override
protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
graphics.fill(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
topPos + SURFACE_BOTTOM, 0xB0000000);
drawBeveledPanel(graphics, leftPos, topPos + 132, imageWidth, 81);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 134 + row * 18);
}
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 192);
}
@Override
protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (inScrollbar(mouseX, mouseY) && button == 0) {
draggingScrollbar = true;
setScrollFromMouse(mouseY, displayedPageSlots());
return true;
}
int index = pageAt(mouseX, mouseY);
if (index >= 0 && (button == 0 || button == 1)) {
ItemStack page = menu.getSlot(index).getItem();
if ((!page.isEmpty() && !matchesSearch(page))
|| (page.isEmpty() && !menu.getCarried().is(ModItems.PAGE.get()))) {
return true;
}
setFocused(null);
textInput.setFocused(false);
selectedSlot = index;
slotClicked(menu.getSlot(index), index, button, ClickType.PICKUP);
surfaceClickPendingRelease = true;
return true;
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) {
if (draggingScrollbar && button == 0) {
setScrollFromMouse(mouseY, displayedPageSlots());
return true;
}
return super.mouseDragged(mouseX, mouseY, button, dragX, dragY);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
if (draggingScrollbar || surfaceClickPendingRelease) {
draggingScrollbar = false;
surfaceClickPendingRelease = false;
return true;
}
draggingScrollbar = false;
return super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (inSurface(mouseX, mouseY) || inScrollbar(mouseX, mouseY)) {
scroll = Math.clamp(scroll - (int)Math.signum(scrollY) * 20,
0, maxScroll(displayedPageSlots()));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (textInput.isFocused()) {
if (keyCode == 256) {
setFocused(null);
textInput.setFocused(false);
} else {
textInput.keyPressed(keyCode, scanCode, modifiers);
}
// EditBox does not consume ordinary letter key presses; the matching charTyped
// event handles text input. Do not pass them to the container's inventory keys.
return true;
}
if (!textInput.isFocused() && selectedSlot >= 0 && (keyCode == 263 || keyCode == 262)) {
moveSelected(keyCode == 262);
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
private int displayedPageSlots() {
if (menu.getCarried().is(ModItems.PAGE.get())) {
return NotebookMenu.NOTEBOOK_SLOTS;
}
for (int index = NotebookMenu.NOTEBOOK_SLOTS - 1; index >= 0; index--) {
if (!menu.getSlot(index).getItem().isEmpty()) {
return index + 1;
}
}
return 0;
}
private boolean matchesSearch(ItemStack page) {
return textInput.getValue().isBlank()
|| page.getHoverName().getString().toLowerCase(Locale.ROOT)
.contains(textInput.getValue().toLowerCase(Locale.ROOT));
}
private int pageAt(double mouseX, double mouseY) {
if (!inSurface(mouseX, mouseY)) return -1;
int localX = (int)mouseX - leftPos;
int localY = (int)mouseY - topPos - SURFACE_TOP + scroll;
int column = localX / PAGE_STEP_X;
int index = (localY / PAGE_STEP_Y) * PAGE_COLUMNS + column;
return column < PAGE_COLUMNS && localX % PAGE_STEP_X < PAGE_WIDTH
&& localY % PAGE_STEP_Y < PAGE_HEIGHT && index < displayedPageSlots()
? index : -1;
}
private boolean inSurface(double mouseX, double mouseY) {
return mouseX >= leftPos && mouseX < leftPos + SURFACE_RIGHT
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private boolean inScrollbar(double mouseX, double mouseY) {
return mouseX >= leftPos + SURFACE_RIGHT && mouseX < leftPos + imageWidth
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private int maxScroll(int pageCount) {
int rows = (pageCount + PAGE_COLUMNS - 1) / PAGE_COLUMNS;
return Math.max(0, rows * PAGE_STEP_Y - (SURFACE_BOTTOM - SURFACE_TOP));
}
private void setScrollFromMouse(double mouseY, int pageCount) {
int maximum = maxScroll(pageCount);
double fraction = (mouseY - topPos - SURFACE_TOP - 10.0)
/ (SURFACE_BOTTOM - SURFACE_TOP - 20.0);
scroll = Math.clamp((int)(Math.clamp(fraction, 0.0, 1.0) * maximum), 0, maximum);
}
private void drawScrollbar(GuiGraphics graphics, int pageCount) {
int x = leftPos + SURFACE_RIGHT;
int y = topPos + SURFACE_TOP;
int height = SURFACE_BOTTOM - SURFACE_TOP;
graphics.fill(x, y, x + 20, y + height, 0xFF858585);
graphics.renderOutline(x, y, 20, height, 0xFF262626);
for (int line = y + 5; line < y + height - 4; line += 5) {
graphics.fill(x + 4, line, x + 16, line + 1, 0xFFB7B7B7);
}
int maximum = maxScroll(pageCount);
int thumbY = y + 4 + (maximum == 0 ? 0 : scroll * (height - 23) / maximum);
graphics.fill(x + 4, thumbY, x + 16, thumbY + 15, 0xFFC9C9C9);
graphics.renderOutline(x + 4, thumbY, 12, 15, 0xFF414141);
}
private void drawPage(GuiGraphics graphics, int index, int x, int y) {
ItemStack page = menu.getSlot(index).getItem();
boolean dimmed = page.isEmpty() || !matchesSearch(page);
// Legacy's Notebook draws the atlas' native 30x40 page, not a stretched item icon.
graphics.blit(PAGE_ATLAS, x, y, PAGE_WIDTH, PAGE_HEIGHT,
156, 0, PAGE_WIDTH, PAGE_HEIGHT, 256, 256);
if (!page.isEmpty()) {
switch (PageItem.content(page).kind()) {
case BLANK -> { }
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000);
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(symbolTexture(page), x + 1, y + 6,
28, 28, 0, 0, 128, 128, 128, 128);
}
}
if (dimmed) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xB8000000);
}
if (index == selectedSlot && !page.isEmpty()) {
graphics.renderOutline(x, y, PAGE_WIDTH, PAGE_HEIGHT, 0xFFFFD061);
}
}
static ResourceLocation symbolTexture(ItemStack page) {
return SymbolPageTextures.forPage(page);
}
private static ResourceLocation texture(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
}
private void moveSelected(boolean next) {
int target = selectedSlot + (next ? 1 : -1);
if (selectedSlot < 0 || target < 0 || target >= NotebookMenu.NOTEBOOK_SLOTS
|| menu.getSlot(selectedSlot).getItem().isEmpty()) return;
int buttonId = (next ? NotebookMenu.MOVE_NEXT_BASE : NotebookMenu.MOVE_PREVIOUS_BASE) + selectedSlot;
if (menu.clickMenuButton(minecraft.player, buttonId)) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
selectedSlot = target;
}
}
private static void drawBeveledPanel(GuiGraphics graphics, int x, int y, int width, int height) {
graphics.fill(x, y, x + width, y + height, 0xFFC6C6C6);
graphics.fill(x, y, x + width, y + 2, 0xFFFFFFFF);
graphics.fill(x, y, x + 2, y + height, 0xFFFFFFFF);
graphics.fill(x, y + height - 2, x + width, y + height, 0xFF555555);
graphics.fill(x + width - 2, y, x + width, y + height, 0xFF555555);
}
private void drawSlot(GuiGraphics graphics, int x, int y) {
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFF8B8B8B);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 1, 0xFF373737);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 1, topPos + y + 18, 0xFF373737);
graphics.fill(leftPos + x + 17, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
graphics.fill(leftPos + x, topPos + y + 17, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
}
}
@@ -0,0 +1,482 @@
package net.dnorberg.rivencraft.client.screen;
import java.util.List;
import java.util.Locale;
import java.util.ArrayList;
import java.util.Comparator;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.client.SymbolPageTextures;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
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> {
private static final ResourceLocation PAGE_ATLAS = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/notebook_pages.png");
private static final ResourceLocation LEGACY_DESK = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/writing_desk_legacy.png");
private static final ResourceLocation SCROLLBAR = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/scrollbar.png");
private int lastCollection = -1;
private int selectedPage;
private int scrollRows;
private boolean allMode;
private boolean sortAlphabetically;
private int selectedCollection = -1;
private ResourceLocation selectedCategory;
private EditBox search;
private Button categoryButton;
private Button allButton;
private Button sortButton;
private boolean draggingScrollbar;
private record DisplayPage(int collection, int index, ItemStack stack) {}
private DisplayPage pageDragSource;
private double pageDragStartX;
private double pageDragStartY;
private boolean draggingPage;
private boolean isPhysicalPageView() {
return !allMode && !sortAlphabetically && selectedCategory == null
&& (search == null || search.getValue().isBlank());
}
public WritingDeskScreen(WritingDeskMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
imageWidth = 409;
imageHeight = 186;
titleLabelX = 222;
}
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() {
super.init();
sortButton = addRenderableWidget(Button.builder(Component.literal("AZ"), button -> {
sortAlphabetically = !sortAlphabetically;
scrollRows = 0;
}).bounds(leftPos + 58, topPos, 20, 18)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.sort"))).build());
allButton = addRenderableWidget(Button.builder(Component.literal("ALL"), button -> {
allMode = !allMode;
scrollRows = 0;
if (!allMode) {
selectedCollection = -1;
selectedPage = 0;
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.PAGE_BUTTON_BASE);
}
}).bounds(leftPos + 80, topPos, 28, 18)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.all"))).build());
search = new EditBox(font, leftPos + 110, topPos, 101, 18,
Component.translatable("screen.rivencraft.writing_desk.search"));
search.setMaxLength(64);
search.setHint(Component.translatable("screen.rivencraft.writing_desk.search"));
search.setResponder(value -> scrollRows = 0);
addRenderableWidget(search);
categoryButton = addRenderableWidget(Button.builder(Component.literal("C"), button -> {
List<ResourceLocation> categories = menu.catalog().stream()
.map(SymbolDisplayMetadata::category).distinct().sorted().toList();
int next = selectedCategory == null ? 0 : categories.indexOf(selectedCategory) + 1;
selectedCategory = next >= categories.size() ? null : categories.get(next);
categoryButton.setMessage(Component.literal(selectedCategory == null ? "C" : "C*"));
scrollRows = 0;
}).bounds(leftPos + 213, topPos, 18, 18).build());
}
private List<DisplayPage> filteredPages() {
String query = search == null ? "" : search.getValue().trim().toLowerCase(Locale.ROOT);
List<DisplayPage> entries = new ArrayList<>();
int from = allMode ? 0 : menu.activeCollection();
int until = allMode ? WritingDeskBlockEntity.COLLECTIONS : from + 1;
for (int collection = from; collection < until; collection++) {
List<ItemStack> pages = pages(collection);
int last = -1;
for (int i = 0; i < pages.size(); i++) if (!pages.get(i).isEmpty()) last = i;
// A notebook has exactly its normal GUI capacity; a loose page has one position.
if (isPhysicalPageView()) last = menu.pageCapacity(collection) - 1;
for (int i = 0; i <= last; i++) {
ItemStack page = i < pages.size() ? pages.get(i) : ItemStack.EMPTY;
if (page.isEmpty() && (allMode || sortAlphabetically || !query.isEmpty() || selectedCategory != null)) continue;
if (!page.isEmpty() && !query.isEmpty()
&& !page.getHoverName().getString().toLowerCase(Locale.ROOT).contains(query)
&& !PageItem.content(page).symbol().map(symbol -> symbol.id().toString()
.toLowerCase(Locale.ROOT).contains(query)).orElse(false)) continue;
if (!page.isEmpty() && selectedCategory != null) {
ResourceLocation id = PageItem.content(page).symbol().map(symbol -> symbol.id()).orElse(null);
if (menu.catalog().stream().noneMatch(metadata -> metadata.id().equals(id)
&& metadata.category().equals(selectedCategory))) continue;
}
entries.add(new DisplayPage(collection, i, page));
}
}
if (sortAlphabetically) entries.sort(Comparator
.comparing((DisplayPage entry) -> entry.stack().getHoverName().getString(), String.CASE_INSENSITIVE_ORDER)
.thenComparingInt(DisplayPage::collection).thenComparingInt(DisplayPage::index));
return entries;
}
@Override public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
// Keep the world visible, but still draw our GUI backing. The vanilla
// container implementation normally calls renderBg from this method.
renderBg(graphics, partialTick, mouseX, mouseY);
}
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
int x = leftPos;
int y = topPos;
// Legacy GuiWritingDesk draws the entire 176x166 panel, not a stretched swatch.
graphics.blit(LEGACY_DESK, x + 233, y + 20, 0, 0, 176, 166);
drawTabControl(graphics, 20, 203, menu.firstVisibleTab() > 0);
for (int i = 0; i < 4; i++) {
int tab = menu.firstVisibleTab() + i;
int tabY = y + 29 + i * 37;
if (tab == menu.activeCollection() && !allMode) graphics.setColor(0.5F, 0.5F, 1F, 1F);
graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37);
graphics.setColor(1F, 1F, 1F, 1F);
// Encode the visible one-based tab number with Legacy's numeric word glyph.
SymbolPageTextures.drawNumeral(graphics, tab + 1, x + 8, tabY + 3, 19);
ItemStack collection = menu.getSlot(tab).getItem();
if (!collection.isEmpty()) {
String name = collection.getHoverName().getString();
float scale = Math.min(1F, 50F / Math.max(1, font.width(name)));
graphics.pose().pushPose();
graphics.pose().translate(x + 4, tabY + 25, 0);
graphics.pose().scale(scale, scale, 1);
graphics.drawString(font, name, 0, 0, 0xFF404040, false);
graphics.pose().popPose();
}
}
drawTabControl(graphics, 177, 212, menu.firstVisibleTab() < WritingDeskBlockEntity.COLLECTIONS - 4);
// GuiElementVSlider: 4-pixel caps, repeated 22-pixel track, 12x15 thumb.
graphics.blit(SCROLLBAR, x + 213, y + 20, 0, 0, 20, 4, 32, 32);
for (int offset = 4; offset < 162; offset += 22)
graphics.blit(SCROLLBAR, x + 213, y + 20 + offset, 0, 4, 20, Math.min(22, 162 - offset), 32, 32);
graphics.blit(SCROLLBAR, x + 213, y + 182, 0, 26, 20, 4, 32, 32);
int totalRows = (filteredPages().size() + 4) / 5;
int maxScroll = Math.max(0, totalRows - 4);
scrollRows = Math.clamp(scrollRows, 0, maxScroll);
int thumbY = y + 24 + (maxScroll == 0 ? 0 : scrollRows * 143 / maxScroll);
graphics.blit(SCROLLBAR, x + 217, thumbY, 20, draggingScrollbar ? 15 : 0, 12, 15, 32, 32);
graphics.fill(x + 365, y + 27, x + 381, y + 97, 0xFF303035);
graphics.fill(x + 366, y + 28, x + 380, y + 96, 0xFF6666AA);
int filled = 68 * menu.inkRemaining() / 16;
if (filled > 0) graphics.fill(x + 366, y + 96 - filled, x + 380, y + 96, 0xFF15151B);
}
private void drawTabControl(GuiGraphics graphics, int y, int textureY, boolean enabled) {
if (!enabled) graphics.setColor(0.4F, 0.4F, 0.4F, 1F);
graphics.blit(LEGACY_DESK, leftPos, topPos + y, 0, textureY, 58, 9);
graphics.setColor(1F, 1F, 1F, 1F);
}
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
// Legacy leaves the inventory grid unlabeled.
}
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
if (sortAlphabetically) graphics.renderOutline(leftPos + 58, topPos, 20, 18, 0xFFFFD061);
if (allMode) graphics.renderOutline(leftPos + 80, topPos, 28, 18, 0xFFFFD061);
renderCollection(graphics);
if (draggingPage && pageDragSource != null) {
DisplayPage target = pageAtMouse(mouseX, mouseY);
if (target != null && canMovePage(pageDragSource, target)) {
int x = leftPos + 58 + ((int) mouseX - leftPos - 58) / 31 * 31;
int y = topPos + 20 + ((int) mouseY - topPos - 20) / 41 * 41;
graphics.renderOutline(x, y, 30, 40, 0xFF57D478);
}
graphics.renderItem(pageDragSource.stack(), mouseX - 8, mouseY - 8);
}
renderTooltip(graphics, mouseX, mouseY);
if (mouseX >= leftPos + 365 && mouseX < leftPos + 381
&& mouseY >= topPos + 27 && mouseY < topPos + 97) {
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.ink",
menu.inkRemaining(), 16), mouseX, mouseY);
}
if (mouseX >= leftPos + 213 && mouseX < leftPos + 231
&& mouseY >= topPos && mouseY < topPos + 18) {
graphics.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);
else if (mouseX < leftPos + 34 && localY >= 29 && localY < 177) {
int tab = menu.firstVisibleTab() + (localY - 29) / 37;
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.tab_symbol",
tab + 1), mouseX, mouseY);
}
}
}
private List<ItemStack> pages(int collectionIndex) {
ItemStack collection = menu.getSlot(collectionIndex).getItem();
if (collection.is(ModItems.PAGE.get())) return List.of(collection);
if (collection.is(ModItems.NOTEBOOK.get())) {
return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
}
return List.of();
}
private void renderCollection(GuiGraphics graphics) {
if (lastCollection != menu.activeCollection()) {
lastCollection = menu.activeCollection();
scrollRows = 0;
selectedPage = 0;
selectedCollection = -1;
}
List<DisplayPage> entries = filteredPages();
for (int i = scrollRows * 5; i < entries.size() && i < scrollRows * 5 + 20; i++) {
int column = i % 5;
int row = i / 5 - scrollRows;
DisplayPage entry = entries.get(i);
ItemStack page = entry.stack();
int x = leftPos + 58 + column * 31;
int y = topPos + 20 + row * 41;
drawPage(graphics, page, x, y);
if (page.isEmpty()) graphics.fill(x, y, x + 30, y + 40, 0xB8000000);
int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
if (entry.collection() == active && entry.index() == selectedPage && !page.isEmpty())
graphics.renderOutline(x, y, 30, 40, 0xFFFFD061);
}
int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
List<ItemStack> source = pages(active);
if (selectedPage >= source.size() || source.get(selectedPage).isEmpty()) return;
ItemStack selected = source.get(selectedPage);
drawPage(graphics, selected, leftPos + 265, topPos + 26);
String title = font.plainSubstrByWidth(selected.getHoverName().getString(), 95);
graphics.fill(leftPos + 261, topPos + 81, leftPos + 360, topPos + 95, 0xFF202020);
graphics.drawString(font, title, leftPos + 263, topPos + 84, 0xFFE0E0E0, false);
}
private void drawPage(GuiGraphics graphics, ItemStack page, int x, int y) {
graphics.blit(PAGE_ATLAS, x, y, 30, 40, 156, 0, 30, 40, 256, 256);
if (page.isEmpty()) return;
switch (PageItem.content(page).kind()) {
case BLANK -> { }
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000);
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page), x + 1, y + 6,
28, 28, 0, 0, 128, 128, 128, 128);
}
}
private DisplayPage pageAtMouse(double mouseX, double mouseY) {
int x = (int) Math.floor(mouseX - leftPos - 58);
int y = (int) Math.floor(mouseY - topPos - 20);
if (x < 0 || x >= 155 || y < 0 || y >= 164 || x % 31 >= 30 || y % 41 >= 40) return null;
int index = (y / 41 + scrollRows) * 5 + x / 31;
List<DisplayPage> entries = filteredPages();
return index < entries.size() ? entries.get(index) : null;
}
private boolean canMovePage(DisplayPage source, DisplayPage target) {
return !source.stack().isEmpty()
&& (menu.getSlot(source.collection()).getItem().is(ModItems.NOTEBOOK.get())
|| menu.getSlot(source.collection()).getItem().is(ModItems.PAGE.get()))
&& (menu.getSlot(target.collection()).getItem().is(ModItems.NOTEBOOK.get())
|| menu.getSlot(target.collection()).getItem().is(ModItems.PAGE.get()))
&& (source.collection() != target.collection() || source.index() != target.index());
}
private int extractTargetSlot(double mouseX, double mouseY) {
for (Slot slot : menu.slots) {
int index = slot.index;
if (index >= WritingDeskBlockEntity.COLLECTIONS && index <= WritingDeskMenu.COPY_RESULT) continue;
if (!slot.isActive() || !isHovering(slot.x, slot.y, 16, 16, mouseX, mouseY)) continue;
ItemStack held = pageDragSource.stack();
if (!slot.mayPlace(held)) return -1;
ItemStack current = slot.getItem();
if (current.isEmpty()) return index;
if (index < WritingDeskBlockEntity.COLLECTIONS && current.is(ModItems.NOTEBOOK.get())) {
List<ItemStack> pages = current.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
if (pages.size() < NotebookContents.MAX_PAGES || pages.stream().anyMatch(ItemStack::isEmpty))
return index;
} else if (index > WritingDeskMenu.COPY_RESULT && ItemStack.isSameItemSameComponents(current, held)
&& current.getCount() < slot.getMaxStackSize(held)) return index;
return -1;
}
return -1;
}
@Override public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button == 0) {
pageDragSource = null;
draggingPage = false;
}
int surfaceX = (int) Math.floor(mouseX - leftPos - 58);
int surfaceY = (int) Math.floor(mouseY - topPos - 20);
if ((button == 0 || button == 1) && !menu.getCarried().isEmpty()
&& surfaceX >= 0 && surfaceX < 155 && surfaceY >= 0 && surfaceY < 164) {
if (menu.getCarried().is(ModItems.PAGE.get()) && surfaceX % 31 < 30 && surfaceY % 41 < 40) {
int index = (surfaceY / 41 + scrollRows) * 5 + surfaceX / 31;
List<DisplayPage> entries = filteredPages();
// Only displayed positions are addressable. Filtering never turns
// empty screen space into additional storage for a loose-page tab.
int collection = index < entries.size() ? entries.get(index).collection() : menu.activeCollection();
int page = index < entries.size() ? entries.get(index).index() : index;
if (index < entries.size() && page < menu.pageCapacity(collection)
&& minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
WritingDeskMenu.PLACE_PAGE_BUTTON_BASE + collection * NotebookContents.MAX_PAGES + page);
}
}
return true;
}
if (button == 0) {
if (mouseX >= leftPos && mouseX < leftPos + 58) {
int y = (int) mouseY - topPos;
if (y >= 20 && y < 29 || y >= 177 && y < 186) {
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
y < 29 ? WritingDeskMenu.PREVIOUS_TABS : WritingDeskMenu.NEXT_TABS);
return true;
}
}
if (mouseX >= leftPos + 213 && mouseX < leftPos + 233
&& mouseY >= topPos + 20 && mouseY < topPos + 186) {
draggingScrollbar = true;
setScrollFromMouse(mouseY);
return true;
}
int x = (int) Math.floor(mouseX - leftPos - 58);
int y = (int) Math.floor(mouseY - topPos - 20);
if (x >= 0 && x < 155 && y >= 0 && y < 164) {
DisplayPage entry = pageAtMouse(mouseX, mouseY);
if (entry != null && !entry.stack().isEmpty()) {
selectedCollection = entry.collection();
selectedPage = entry.index();
if (menu.getCarried().isEmpty()) {
pageDragSource = entry;
pageDragStartX = mouseX;
pageDragStartY = mouseY;
}
if (minecraft != null && minecraft.gameMode != null) {
int id = WritingDeskMenu.ALL_PAGE_BUTTON_BASE
+ entry.collection() * NotebookContents.MAX_PAGES + entry.index();
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
}
}
return true;
}
}
if (button == 0 && mouseX >= leftPos && mouseX < leftPos + 58) {
for (int i = 0; i < 4; i++) {
int top = topPos + 29 + i * 37;
boolean overSlot = mouseX >= leftPos + 36 && mouseX < leftPos + 54
&& mouseY >= top + 4 && mouseY < top + 22;
if (mouseY >= top && mouseY < top + 37 && !overSlot) {
allMode = false;
selectedCollection = -1;
if (minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, menu.firstVisibleTab() + i);
}
return true;
}
}
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (mouseX >= leftPos + 58 && mouseX < leftPos + 233
&& mouseY >= topPos + 20 && mouseY < topPos + 186) {
int rows = (filteredPages().size() + 4) / 5;
scrollRows = Math.clamp(scrollRows - (int) Math.signum(scrollY), 0, Math.max(0, rows - 4));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
private void setScrollFromMouse(double mouseY) {
int rows = (filteredPages().size() + 4) / 5;
int max = Math.max(0, rows - 4);
scrollRows = Math.clamp((int) Math.round((mouseY - topPos - 31.5) * max / 143.0), 0, max);
}
@Override public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) {
if (button == 0 && pageDragSource != null) {
if (Math.abs(mouseX - pageDragStartX) >= 4 || Math.abs(mouseY - pageDragStartY) >= 4)
draggingPage = true;
return true;
}
if (button == 0 && draggingScrollbar) {
setScrollFromMouse(mouseY);
return true;
}
return super.mouseDragged(mouseX, mouseY, button, dragX, dragY);
}
@Override public boolean mouseReleased(double mouseX, double mouseY, int button) {
if (button == 0 && pageDragSource != null) {
if (draggingPage) {
DisplayPage target = pageAtMouse(mouseX, mouseY);
if (target != null && canMovePage(pageDragSource, target)
&& minecraft != null && minecraft.gameMode != null) {
selectedCollection = target.collection();
selectedPage = target.index();
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
WritingDeskMenu.movePageButtonId(pageDragSource.collection(), pageDragSource.index(),
target.collection(), target.index()));
} else if (target == null && minecraft != null && minecraft.gameMode != null) {
int targetSlot = extractTargetSlot(mouseX, mouseY);
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
WritingDeskMenu.extractPageButtonId(pageDragSource.collection(), pageDragSource.index(),
targetSlot));
}
}
pageDragSource = null;
draggingPage = false;
return true;
}
if (button == 0 && draggingScrollbar) {
draggingScrollbar = false;
return true;
}
return super.mouseReleased(mouseX, mouseY, button);
}
@Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (search != null && search.isFocused()) {
if (keyCode == 256) {
setFocused(null);
search.setFocused(false);
} else {
search.keyPressed(keyCode, scanCode, modifiers);
}
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,81 @@
package net.dnorberg.rivencraft.command;
import com.mojang.brigadier.arguments.StringArgumentType;
import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
/** Administrative commands for inspecting and safely unloading persistent Ages. */
public final class RivencraftCommands {
private RivencraftCommands() {
}
public static void register(RegisterCommandsEvent event) {
event.getDispatcher().register(Commands.literal("rivencraft")
.requires(source -> source.hasPermission(2))
.then(Commands.literal("age")
.then(Commands.literal("list")
.executes(context -> listAges(context.getSource())))
.then(Commands.literal("unload")
.then(Commands.argument("dimension", StringArgumentType.string())
.suggests((context, builder) -> {
AgeRegistrySavedData.get(context.getSource().getLevel()).definitions()
.forEach(age -> builder.suggest(age.dimensionId().toString()));
return builder.buildFuture();
})
.executes(context -> unloadAge(
context.getSource(),
StringArgumentType.getString(context, "dimension")
))))));
}
private static int listAges(net.minecraft.commands.CommandSourceStack source) {
var ages = AgeRegistrySavedData.get(source.getLevel()).definitions().stream()
.sorted(java.util.Comparator.comparing(AgeDefinition::name))
.toList();
source.sendSuccess(() -> Component.literal("Rivencraft Ages: " + ages.size()), false);
for (AgeDefinition age : ages) {
boolean loaded = source.getServer().getLevel(AgeDimensionService.dimensionKey(age)) != null;
source.sendSuccess(() -> Component.literal(
(loaded ? "[loaded] " : "[unloaded] ")
+ age.name() + " — " + age.dimensionId()
), false);
}
return ages.size();
}
private static int unloadAge(net.minecraft.commands.CommandSourceStack source, String requestedDimension) {
ResourceLocation dimension = ResourceLocation.tryParse(requestedDimension);
if (dimension == null) {
source.sendFailure(Component.literal("Invalid dimension id: " + requestedDimension));
return 0;
}
AgeDefinition age = AgeRegistrySavedData.get(source.getLevel()).findByDimension(dimension).orElse(null);
if (age == null) {
source.sendFailure(Component.literal("Unknown Rivencraft Age: " + dimension));
return 0;
}
AgeDimensionService.UnloadResult result = AgeDimensionService.unload(source.getServer(), age);
return switch (result) {
case UNLOADED -> {
source.sendSuccess(() -> Component.literal(
"Unloaded " + age.name() + ". Its definition and world data were retained."
), true);
yield 1;
}
case ALREADY_UNLOADED -> {
source.sendFailure(Component.literal(age.name() + " is already unloaded."));
yield 0;
}
case FAILED -> {
source.sendFailure(Component.literal("Dynamic Dimensions refused to unload " + age.name() + "."));
yield 0;
}
};
}
}
@@ -1,11 +1,13 @@
package net.dnorberg.rivencraft.compat;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.WeakHashMap;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import qouteall.imm_ptl.core.api.PortalAPI;
import qouteall.imm_ptl.core.chunk_loading.ChunkLoader;
@@ -14,12 +16,12 @@ import qouteall.q_misc_util.dimension.DimensionIntId;
/** Isolated server bridge; this class is loaded only when Immersive Portals is installed. */
public final class ImmersivePortalsChunkLoaders {
private static final Map<ServerPlayer, ChunkLoader> LOADERS = new WeakHashMap<>();
private static final Map<ServerPlayer, ChunkLoader> LOADERS = new IdentityHashMap<>();
private ImmersivePortalsChunkLoaders() {
}
public static void open(ServerPlayer player, ServerLevel level, BlockPos center) {
public static synchronized void open(ServerPlayer player, ServerLevel level, BlockPos center) {
close(player);
int centerChunkX = center.getX() >> 4;
int centerChunkZ = center.getZ() >> 4;
@@ -37,13 +39,28 @@ public final class ImmersivePortalsChunkLoaders {
ImmPtlChunkTracking.immediatelyUpdateForPlayer(player);
}
public static void close(ServerPlayer player) {
public static synchronized void close(ServerPlayer player) {
ChunkLoader loader = LOADERS.remove(player);
if (loader != null) {
PortalAPI.removeChunkLoaderForPlayer(player, loader);
}
}
public static synchronized void closeForDimension(ResourceKey<Level> dimension) {
LOADERS.entrySet().removeIf(entry -> {
if (!entry.getValue().dimension().equals(dimension)) {
return false;
}
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue());
return true;
});
}
public static synchronized void closeAll() {
LOADERS.forEach(PortalAPI::removeChunkLoaderForPlayer);
LOADERS.clear();
}
/** Registers runtime dimensions and sends the revised ID map to connected clients. */
public static void dimensionsChanged(MinecraftServer server) {
DimensionIntId.onServerDimensionChanged(server);
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.compat;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
/** Pure selection policy for optional destination preview integrations. */
public final class PreviewBackendSelector {
public enum Backend {
IMMERSIVE_PORTALS,
PONDER,
LEGACY
}
private PreviewBackendSelector() {
}
public static Backend select(
RivencraftClientConfig.PreviewMode mode,
boolean immersivePortalsAvailable,
boolean ponderAvailable
) {
return switch (mode) {
case AUTO -> immersivePortalsAvailable
? Backend.IMMERSIVE_PORTALS
: ponderAvailable ? Backend.PONDER : Backend.LEGACY;
case IMMERSIVE_PORTALS -> immersivePortalsAvailable ? Backend.IMMERSIVE_PORTALS : Backend.LEGACY;
case PONDER -> ponderAvailable ? Backend.PONDER : Backend.LEGACY;
case LEGACY -> Backend.LEGACY;
};
}
}
@@ -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);
}
}
}
@@ -15,6 +15,8 @@ public final class RivencraftConfig {
public static final ModConfigSpec SPEC;
private static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
static {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
@@ -25,6 +27,12 @@ public final class RivencraftConfig {
)
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
builder.pop();
builder.push("writing");
SYMBOL_INK_COST = builder.comment("Ink units consumed when the Writing Desk copies one symbol page.")
.defineInRange("symbol_ink_cost", 1, 1, 16);
LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline link-panel page.")
.defineInRange("link_panel_ink_cost", 2, 1, 16);
builder.pop();
SPEC = builder.build();
}
@@ -34,4 +42,12 @@ public final class RivencraftConfig {
public static ReturnLinkMode returnLinkMode() {
return RETURN_LINK_MODE.get();
}
public static int symbolInkCost() {
return SYMBOL_INK_COST.get();
}
public static int linkPanelInkCost() {
return LINK_PANEL_INK_COST.get();
}
}
@@ -0,0 +1,26 @@
package net.dnorberg.rivencraft.event;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.fml.ModList;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
/** Server-authoritative cleanup for resources that exist only while a preview is active. */
public final class AgeLifecycleEvents {
private AgeLifecycleEvents() {
}
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer player
&& ModList.get().isLoaded("immersive_portals_core")) {
ImmersivePortalsChunkLoaders.close(player);
}
}
public static void onServerStopping(ServerStoppingEvent event) {
if (ModList.get().isLoaded("immersive_portals_core")) {
ImmersivePortalsChunkLoaders.closeAll();
}
}
}
@@ -0,0 +1,374 @@
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.SymbolCatalog;
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");
restored.getItem(BookBinderBlockEntity.FIRST_PAGE).set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
SymbolCatalog.display(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))));
helper.assertFalse(restored.canAssemble(),
"Forged display metadata cannot make a missing symbol valid for a new Age");
helper.assertTrue(restored.getItem(BookBinderBlockEntity.FIRST_PAGE).is(ModItems.PAGE.get()),
"Rejected writing must remain recoverable as a Page");
helper.succeed();
}
@GameTest(template = "empty")
public static void conflictingGrammarKeepsBinderInputsRecoverable(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setTitle("Recoverable Duplicate");
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage());
binder.setItem(BookBinderBlockEntity.FIRST_PAGE + 5, terrainPage());
helper.assertFalse(binder.canAssemble(), "Duplicate terrain must not assemble a new Age");
int errorEntry = binder.writingGrammar().firstError().orElseThrow().index();
helper.assertValueEqual(errorEntry, 2, "Parser must identify the second authored symbol");
helper.assertValueEqual(binder.writingStripIndex(errorEntry), 6,
"A grammar error must highlight the correct strip page despite empty positions");
helper.assertFalse(binder.consumeAssembly(), "Rejected grammar must not consume binder inputs");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER),
"Rejected grammar must leave the cover recoverable");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()),
"Rejected grammar must leave the Link Panel recoverable");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 5).is(ModItems.PAGE.get()),
"Rejected grammar must leave the duplicate page removable");
ItemStack removed = binder.takePage(5);
helper.assertTrue(removed.is(ModItems.PAGE.get()), "Player must be able to correct the duplicate page");
helper.assertTrue(binder.canAssemble(), "Corrected partial writing must become assemblable");
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,109 @@
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 semanticSymbolSchema(GameTestHelper helper) {
WritingDataGameTests.semanticSchemaIsServerOnlyBoundedAndV1Compatible(helper);
}
@GameTest(template = "empty")
public static void orderedWritingGrammar(GameTestHelper helper) {
WritingGrammarGameTests.orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences(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 binderRejectsConflictingGrammar(GameTestHelper helper) {
BookBinderGameTests.conflictingGrammarKeepsBinderInputsRecoverable(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();
}
}
@@ -6,10 +6,14 @@ import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.AgeRuntimeProfile;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.book.AgeVisitSavedData;
import net.dnorberg.rivencraft.book.AgeTraits;
import net.dnorberg.rivencraft.compat.PreviewBackendSelector;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
@@ -30,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);
@@ -38,7 +42,90 @@ public final class LinkingGameTests {
private LinkingGameTests() {
}
public static void previewBackendSelectionCoversOptionalDependencyMatrix(GameTestHelper helper) {
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, true,
PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, false,
PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, false, true,
PreviewBackendSelector.Backend.PONDER);
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, false, false,
PreviewBackendSelector.Backend.LEGACY);
for (boolean ponderAvailable : new boolean[] {false, true}) {
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.IMMERSIVE_PORTALS, true,
ponderAvailable, PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.IMMERSIVE_PORTALS, false,
ponderAvailable, PreviewBackendSelector.Backend.LEGACY);
}
for (boolean immersivePortalsAvailable : new boolean[] {false, true}) {
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.PONDER,
immersivePortalsAvailable, true, PreviewBackendSelector.Backend.PONDER);
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.PONDER,
immersivePortalsAvailable, false, PreviewBackendSelector.Backend.LEGACY);
}
for (boolean immersivePortalsAvailable : new boolean[] {false, true}) {
for (boolean ponderAvailable : new boolean[] {false, true}) {
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.LEGACY,
immersivePortalsAvailable, ponderAvailable, PreviewBackendSelector.Backend.LEGACY);
}
}
helper.succeed();
}
private static void assertPreviewBackend(
GameTestHelper helper,
RivencraftClientConfig.PreviewMode mode,
boolean immersivePortalsAvailable,
boolean ponderAvailable,
PreviewBackendSelector.Backend expected
) {
helper.assertValueEqual(
PreviewBackendSelector.select(mode, immersivePortalsAvailable, ponderAvailable),
expected,
mode + " selection with Immersive Portals=" + immersivePortalsAvailable
+ " and Ponder=" + ponderAvailable
);
}
@GameTest(template = "empty")
public static void dataPackAgeProfilesResolveRegisteredResources(GameTestHelper helper) {
var registries = helper.getLevel().registryAccess();
var profiles = AgeRuntimeProfiles.profiles();
helper.assertTrue(profiles.keySet().containsAll(AgeGenerationProfiles.all()),
"Every built-in Age runtime profile must survive data-pack validation");
for (var entry : profiles.entrySet()) {
AgeRuntimeProfile profile = entry.getValue();
helper.assertValueEqual(profile.schemaVersion(), AgeRuntimeProfile.CURRENT_SCHEMA_VERSION,
entry.getKey() + " must use the current runtime-profile schema");
helper.assertTrue(registries.lookupOrThrow(net.minecraft.core.registries.Registries.DIMENSION_TYPE)
.get(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.DIMENSION_TYPE,
profile.dimensionType())).isPresent(),
entry.getKey() + " must reference a registered dimension type");
if (profile.terrain() == AgeRuntimeProfile.Terrain.NOISE) {
helper.assertTrue(registries.lookupOrThrow(net.minecraft.core.registries.Registries.NOISE_SETTINGS)
.get(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.NOISE_SETTINGS,
profile.noiseSettings())).isPresent(),
entry.getKey() + " must reference registered noise settings");
helper.assertTrue(registries.lookupOrThrow(
net.minecraft.core.registries.Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST)
.get(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST,
profile.biomePreset())).isPresent(),
entry.getKey() + " must reference a registered biome preset");
}
}
var missing = net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("example", "removed_profile");
helper.assertValueEqual(
AgeRuntimeProfiles.resolve(missing),
AgeRuntimeProfiles.resolve(AgeGenerationProfiles.STANDARD),
"A removed custom profile must use the documented deterministic fallback"
);
helper.succeed();
}
public static void linkTargetSurvivesItemSerialization(GameTestHelper helper) {
LinkTarget expected = new LinkTarget(
GlobalPos.of(helper.getLevel().dimension(), helper.absolutePos(ORIGIN)),
@@ -92,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;
@@ -152,6 +238,114 @@ public final class LinkingGameTests {
});
}
@GameTest(template = "empty", timeoutTicks = 400)
public static void everyTerrainProfileProvidesSafeArrivalAndReturnTravel(GameTestHelper helper) {
ServerPlayer player = helper.makeMockServerPlayerInLevel();
BlockPos returnPosition = player.blockPosition();
helper.getLevel().setBlock(returnPosition.below(), Blocks.STONE.defaultBlockState(), 2);
helper.getLevel().setBlock(returnPosition, Blocks.AIR.defaultBlockState(), 2);
helper.getLevel().setBlock(returnPosition.above(), Blocks.AIR.defaultBlockState(), 2);
LinkTarget returnTarget = new LinkTarget(
GlobalPos.of(helper.getLevel().dimension(), returnPosition),
player.getYRot(),
player.getXRot()
);
long worldSeed = helper.getLevel().getServer().overworld().getSeed();
runTerrainProfileTravel(helper, player, returnTarget, AgeGenerationProfiles.all(), 0, worldSeed);
}
private static void runTerrainProfileTravel(
GameTestHelper helper,
ServerPlayer player,
LinkTarget returnTarget,
java.util.List<net.minecraft.resources.ResourceLocation> profiles,
int index,
long worldSeed
) {
if (index >= profiles.size()) {
helper.succeed();
return;
}
var profile = profiles.get(index);
AgeReference reference = referenceSelectingProfile(profile, worldSeed);
LinkTarget destination = AgeDimensionService.resolve(player, reference).orElse(null);
helper.assertTrue(destination != null, profile + " must create a destination");
if (destination == null) {
return;
}
helper.runAfterDelay(1, () -> {
AgeDefinition definition = AgeRegistrySavedData.get(helper.getLevel()).find(reference.id()).orElseThrow();
helper.assertValueEqual(definition.generationProfile(), profile,
"The acceptance fixture must select " + profile);
var ageLevel = player.getServer().getLevel(destination.location().dimension());
helper.assertTrue(ageLevel != null, profile + " must create a loaded runtime dimension");
BlockPos spawn = destination.location().pos();
helper.assertTrue(ageLevel != null && ageLevel.getBlockState(spawn.below()).is(Blocks.COBBLESTONE),
profile + " must create a solid arrival platform");
helper.assertTrue(ageLevel != null && ageLevel.getBlockState(spawn).isAir()
&& ageLevel.getBlockState(spawn.above()).isAir(),
profile + " must create clear arrival headroom");
helper.assertValueEqual(LinkingService.travelWithFooting(player, destination),
LinkingService.LinkResult.SUCCESS, profile + " must accept safe inbound travel");
helper.assertValueEqual(LinkingService.travel(player, returnTarget),
LinkingService.LinkResult.SUCCESS, profile + " must permit return travel");
helper.runAfterDelay(1, () -> runTerrainProfileTravel(
helper, player, returnTarget, profiles, index + 1, worldSeed));
});
}
private static AgeReference referenceSelectingProfile(
net.minecraft.resources.ResourceLocation profile,
long worldSeed
) {
UUID id = UUID.randomUUID();
for (int candidate = 0; candidate < 100_000; candidate++) {
String name = "Profile Acceptance " + profile.getPath() + " " + id + " " + candidate;
var dimension = AgeDefinition.namedDimensionId(name);
long seed = worldSeed + dimension.hashCode();
if (AgeRuntimeProfiles.select(seed).equals(profile)) {
return new AgeReference(id, name);
}
}
throw new IllegalStateException("Could not select profile " + profile);
}
@GameTest(template = "empty", timeoutTicks = 100)
public static void unloadedAgeReloadsWithTheSameDefinition(GameTestHelper helper) {
UUID id = UUID.randomUUID();
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(
helper.getLevel(),
new AgeReference(id, "Lifecycle Test Age " + id)
);
var server = helper.getLevel().getServer();
var first = AgeDimensionService.ensureLoaded(server, definition);
helper.assertTrue(first != null, "The lifecycle test Age must load");
helper.runAfterDelay(1, () -> {
helper.assertValueEqual(
AgeDimensionService.unload(server, definition),
AgeDimensionService.UnloadResult.UNLOADED,
"A loaded Age must unload without deleting its definition"
);
helper.runAfterDelay(1, () -> {
helper.assertTrue(server.getLevel(AgeDimensionService.dimensionKey(definition)) == null,
"An unloaded Age must leave the active server level map");
var reloaded = AgeDimensionService.ensureLoaded(server, definition);
helper.assertTrue(reloaded != null, "A book must be able to reload an archived Age");
helper.runAfterDelay(1, () -> {
var registered = server.getLevel(AgeDimensionService.dimensionKey(definition));
helper.assertTrue(registered != null, "The reloaded Age must rejoin the active server level map");
helper.assertValueEqual(registered.getSeed(), definition.seed(),
"Reloading an Age must preserve its immutable seed");
helper.assertValueEqual(registered.dimension().location(), definition.dimensionId(),
"Reloading an Age must preserve its dimension identifier");
helper.succeed();
});
});
});
}
@GameTest(template = "empty", timeoutTicks = 200)
public static void legacyBaseTerrainProfilesLoadRuntimeDimensions(GameTestHelper helper) {
for (var profile : AgeGenerationProfiles.all()) {
@@ -404,7 +598,9 @@ public final class LinkingGameTests {
}
helper.assertTrue(level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream().allMatch(holder -> holder.is(biome)),
"The material test Age must select " + biome.location());
// Sample away from the generated arrival platform around the Age spawn.
// Sample away from the generated arrival platform. Vanilla surface rules
// can place a shallow local patch such as gravel over the biome's base block.
boolean foundExpectedSurface = false;
int sampleX = 64;
int sampleZ = 64;
level.getChunk(sampleX >> 4, sampleZ >> 4);
@@ -413,10 +609,15 @@ public final class LinkingGameTests {
sampleX,
sampleZ
) - 1;
var surfaceState = level.getBlockState(new BlockPos(sampleX, surfaceY, sampleZ));
helper.assertTrue(surfaceState.is(expectedBlock),
for (int depth = 0; depth <= 8; depth++) {
if (level.getBlockState(new BlockPos(sampleX, surfaceY - depth, sampleZ)).is(expectedBlock)) {
foundExpectedSurface = true;
break;
}
}
helper.assertTrue(foundExpectedSurface,
biome.location() + " terrain must expose " + expectedBlock.getDescriptionId()
+ " at the surface, found " + surfaceState);
+ " across the sampled surface");
}
@GameTest(template = "empty")
@@ -462,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(
@@ -0,0 +1,427 @@
package net.dnorberg.rivencraft.gametest;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import io.netty.buffer.Unpooled;
import java.util.Arrays;
import java.util.List;
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;
import net.dnorberg.rivencraft.writing.SymbolDefinition;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.dnorberg.rivencraft.writing.SymbolPoems;
import net.dnorberg.rivencraft.writing.SymbolNumerals;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.inventory.ClickType;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Acceptance coverage for Milestone 4's persistent writing contracts. */
@GameTestHolder("rivencraft_archive_writing")
@PrefixGameTestTemplate(false)
public final class WritingDataGameTests {
private WritingDataGameTests() {
}
@GameTest(template = "empty")
public static void orderedWritingPreservesKnownUnknownAndLinkPanelEntries(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference unknown = reference("example:removed_symbol");
OrderedWriting original = new OrderedWriting(List.of(
WritingEntry.symbol(known),
WritingEntry.symbol(unknown),
WritingEntry.linkPanel()
));
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");
FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer());
OrderedWriting.STREAM_CODEC.encode(buffer, original);
OrderedWriting networkCopy = OrderedWriting.STREAM_CODEC.decode(buffer);
helper.assertValueEqual(networkCopy, original,
"Network writing codec must retain entry order and unknown symbol IDs");
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");
SymbolReference unknown = reference("example:removed_symbol");
helper.assertTrue(SymbolCatalog.symbols().size() >= 6,
"The built-in catalog must exercise every Milestone 4 writing workflow");
helper.assertTrue(SymbolCatalog.isKnown(known),
"The built-in Standard Terrain symbol must be server-authoritative");
helper.assertFalse(SymbolCatalog.isKnown(unknown),
"An absent data-pack symbol must remain unresolved");
var unresolved = SymbolCatalog.display(unknown);
helper.assertFalse(unresolved.resolved(),
"Unknown symbol display metadata must be marked unresolved");
helper.assertValueEqual(unresolved.id(), unknown.id(),
"Unresolved display metadata must preserve the original namespaced ID");
helper.succeed();
}
@GameTest(template = "empty")
public static void semanticSchemaIsServerOnlyBoundedAndV1Compatible(GameTestHelper helper) {
var builtIn = SymbolCatalog.find(reference("rivencraft:terrain/standard")).orElseThrow();
helper.assertValueEqual(builtIn.schemaVersion(), 2, "Built-in symbol must use semantic schema v2");
helper.assertValueEqual(builtIn.semantics().orElseThrow().role(), SymbolSemantics.Role.TERRAIN,
"Built-in terrain symbol must declare its grammar role");
helper.assertValueEqual(builtIn.poem(), "Terrain Form Tradition Flow",
"Built-in page artwork must carry its four-word Legacy poem");
helper.assertValueEqual(SymbolPoems.builtIn(reference("rivencraft:terrain/standard").id()), builtIn.poem(),
"Pre-artwork saved starter pages must retain their original poem");
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(0), new int[] {1}),
"Zero must use Legacy's dedicated number mark");
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(1), new int[] {56}),
"One must use Legacy's first bottom-row mark");
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(24), new int[] {63, 59}),
"Twenty-four must combine Legacy's five and unit marks");
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(25), new int[] {1, 0}),
"Twenty-five must start a second base-25 place");
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(88), new int[] {3, 13}),
"Last writing-desk tab must retain its numeric value");
String display = "\"name\":\"symbol.example.test\","
+ "\"description\":\"symbol.example.test.description\","
+ "\"category\":\"example:terrain\"";
SymbolDefinition legacy = SymbolDefinition.parse(JsonParser.parseString(
"{\"schema_version\":1," + display + "}"));
helper.assertTrue(legacy.semantics().isEmpty(), "Existing v1 definitions must remain display-only");
SymbolDefinition oldCategory = SymbolDefinition.parse(JsonParser.parseString(
"{\"schema_version\":1,\"name\":\"symbol.example.test\","
+ "\"description\":\"symbol.example.test.description\","
+ "\"category\":\"terrain\"}"));
helper.assertValueEqual(oldCategory.category().toString(), "minecraft:terrain",
"A v1 category without a namespace must retain the old ResourceLocation behavior");
helper.assertValueEqual(legacy.display(reference("example:test").id()).id(),
reference("example:test").id(), "Display metadata must preserve the symbol ID");
SymbolDefinition period = SymbolDefinition.parse(JsonParser.parseString(
"{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"period_modifier\",\"value\":0}}"));
helper.assertValueEqual(period.semantics().orElseThrow().role(), SymbolSemantics.Role.PERIOD_MODIFIER,
"Zero-length modifier must be accepted");
rejectDefinition(helper, "{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"unknown\",\"value\":\"normal\"}}", "role");
rejectDefinition(helper, "{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"weather\",\"value\":\"blizzard\"}}", "value");
rejectDefinition(helper, "{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"period_modifier\",\"value\":2.01}}", "value");
rejectDefinition(helper, "{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"terrain\",\"value\":\"minecraft:overworld\",\"extra\":1}}", "extra");
rejectDefinition(helper, "{\"schema_version\":1," + display
+ ",\"semantics\":{\"role\":\"weather\",\"value\":\"normal\"}}", "v1");
var clientJson = builtIn.display(reference("rivencraft:terrain/standard").id());
var encoded = net.dnorberg.rivencraft.writing.SymbolDisplayMetadata.CODEC
.encodeStart(JsonOps.INSTANCE, clientJson).result().orElseThrow();
helper.assertFalse(encoded.getAsJsonObject().has("semantics"),
"Client display payload must never contain generation semantics");
helper.assertValueEqual(encoded.getAsJsonObject().get("poem").getAsString(), builtIn.poem(),
"Client display payload must carry page artwork metadata");
rejectDefinition(helper, "{\"schema_version\":2," + display
+ ",\"semantics\":{\"role\":\"weather\",\"value\":\"normal\"},\"poem\":\"only two\"}", "poem");
helper.succeed();
}
private static void rejectDefinition(GameTestHelper helper, String json, String diagnostic) {
boolean rejected = false;
try {
SymbolDefinition.parse(JsonParser.parseString(json));
} catch (IllegalArgumentException exception) {
rejected = exception.getMessage().contains(diagnostic);
}
helper.assertTrue(rejected, "Invalid symbol must be rejected with diagnostic containing '" + diagnostic + "'");
}
@GameTest(template = "empty")
public static void pageStatesPersistAndNetworkRoundTrip(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference removed = reference("example:removed_symbol");
List<PageContent> states = List.of(
PageContent.BLANK,
PageContent.symbol(known),
PageContent.unresolved(removed),
PageContent.LINK_PANEL
);
for (PageContent state : states) {
var json = PageContent.CODEC.encodeStart(JsonOps.INSTANCE, state).result().orElseThrow();
PageContent persistentCopy = PageContent.CODEC.parse(JsonOps.INSTANCE, json).result().orElseThrow();
helper.assertValueEqual(persistentCopy, state, "Page content must persist exactly");
FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer());
PageContent.STREAM_CODEC.encode(buffer, state);
helper.assertValueEqual(PageContent.STREAM_CODEC.decode(buffer), state,
"Page content must network-sync exactly");
}
helper.succeed();
}
public static void pageItemPreservesContentAndRejectsForgedUnknownSymbols(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference unknown = reference("example:forged_symbol");
ItemStack page = PageItem.createSymbolPage(known);
CompoundTag serialized = (CompoundTag) page.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), serialized);
helper.assertValueEqual(restored.get(ModDataComponents.PAGE_CONTENT.get()), PageContent.symbol(known),
"Symbol page content must survive an ItemStack save/load");
helper.assertValueEqual(
restored.get(ModDataComponents.PAGE_SYMBOL_DISPLAY.get()),
SymbolCatalog.display(known),
"Server-selected display metadata must survive for remote clients"
);
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 {
PageItem.createSymbolPage(unknown);
helper.fail("The server must reject new pages for unknown symbols");
} catch (IllegalArgumentException expected) {
helper.succeed();
}
}
public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) {
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
net.minecraft.network.chat.Component.literal("Foundation"));
ItemStack unknown = PageItem.createRecoveredPage(reference("example:removed_symbol"));
NotebookContents contents = new NotebookContents(List.of(first, ItemStack.EMPTY, unknown));
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), contents);
CompoundTag serialized = (CompoundTag)notebook.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), serialized);
NotebookContents restoredContents = restored.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
helper.assertTrue(restoredContents != null, "Notebook contents must survive ItemStack persistence");
helper.assertValueEqual(restoredContents.pages().size(), 3,
"Notebook persistence must retain ordered positions");
helper.assertTrue(restoredContents.pages().get(1).isEmpty(),
"Notebook persistence must retain empty positions between pages");
helper.assertValueEqual(restoredContents.pages().get(0).getHoverName().getString(), "Foundation",
"Renamed contained pages must retain their names");
helper.assertValueEqual(PageItem.content(restoredContents.pages().get(2)).kind(),
PageContent.Kind.UNRESOLVED_SYMBOL,
"Unknown symbol pages must remain present and removable");
helper.succeed();
}
@GameTest(template = "empty")
public static void notebookMenuValidatesInsertionAndPersistsReorder(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
player.setItemInHand(InteractionHand.MAIN_HAND, notebook);
NotebookMenu menu = new NotebookMenu(7, player.getInventory(), InteractionHand.MAIN_HAND);
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
net.minecraft.network.chat.Component.literal("Terrain Base"));
ItemStack second = PageItem.createRecoveredPage(reference("example:removed_symbol"));
helper.assertFalse(menu.getSlot(0).mayPlace(new ItemStack(Items.DIRT)),
"Notebook slots must reject non-Page items");
menu.getSlot(0).set(first);
menu.getSlot(1).set(second);
helper.assertTrue(menu.clickMenuButton(player, NotebookMenu.MOVE_NEXT_BASE),
"The server menu must accept a valid adjacent reorder");
NotebookContents saved = notebook.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
helper.assertTrue(saved != null, "Menu changes must persist immediately to the Notebook item");
helper.assertValueEqual(saved.pages().get(1).getHoverName().getString(), "Terrain Base",
"Reordering must preserve existing Page item data");
helper.assertValueEqual(PageItem.content(saved.pages().get(0)).kind(),
PageContent.Kind.UNRESOLVED_SYMBOL,
"Reordering must preserve unresolved pages");
helper.succeed();
}
@GameTest(template = "empty")
public static void notebookMenuSurvivesInventoryStackReplacementAndClicks(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(ModItems.NOTEBOOK.get()));
NotebookMenu menu = new NotebookMenu(8, player.getInventory(), InteractionHand.MAIN_HAND);
// Slot synchronization may replace the ItemStack object while the Notebook remains held.
player.setItemInHand(InteractionHand.MAIN_HAND, player.getMainHandItem().copy());
helper.assertTrue(menu.stillValid(player),
"Replacing the held stack object must not close an open Notebook menu");
menu.getSlot(0).set(PageItem.createSymbolPage(reference("rivencraft:terrain/standard")));
helper.assertTrue(player.getMainHandItem().has(ModDataComponents.NOTEBOOK_CONTENTS.get()),
"Page edits must persist to the current held Notebook stack");
player.getInventory().setItem(9, new ItemStack(Items.DIRT));
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS, 0, ClickType.PICKUP, player);
helper.assertTrue(menu.getCarried().is(Items.DIRT),
"Clicking a player inventory item must pick it up without closing the Notebook");
helper.assertTrue(menu.stillValid(player),
"Normal inventory clicks must leave the Notebook menu valid");
helper.succeed();
}
@GameTest(template = "empty")
public static void notebookMenuPreservesSparsePagePositions(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(ModItems.NOTEBOOK.get()));
NotebookMenu menu = new NotebookMenu(9, player.getInventory(), InteractionHand.MAIN_HAND);
ItemStack page = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
menu.setCarried(page);
menu.clicked(9, 0, ClickType.PICKUP, player);
helper.assertTrue(menu.getCarried().isEmpty(),
"Clicking a distant empty Notebook slot must place the carried Page");
NotebookContents saved = player.getMainHandItem().get(ModDataComponents.NOTEBOOK_CONTENTS.get());
helper.assertTrue(saved != null && saved.pages().size() > 9,
"Notebook persistence must extend through the highest occupied position");
for (int index = 0; index < 9; index++) {
helper.assertTrue(saved.pages().get(index).isEmpty(),
"Earlier empty page positions must remain empty rather than being packed together");
}
helper.assertValueEqual(PageItem.content(saved.pages().get(9)).kind(), PageContent.Kind.SYMBOL,
"The Page must remain in the selected distant position");
helper.succeed();
}
@GameTest(template = "empty")
public static void inkVialsPersistAndOnlyStackWithMatchingContents(GameTestHelper helper) {
ItemStack vial = new ItemStack(ModItems.INK_VIAL.get());
vial.set(ModDataComponents.INK_REMAINING.get(), 7);
CompoundTag serialized = (CompoundTag) vial.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), serialized);
helper.assertValueEqual(InkVialItem.remaining(restored), 7,
"Remaining ink must survive ItemStack persistence");
helper.assertTrue(ItemStack.isSameItemSameComponents(vial, restored),
"Vials with equal ink contents must be eligible to stack");
helper.assertFalse(ItemStack.isSameItemSameComponents(vial, new ItemStack(ModItems.INK_VIAL.get())),
"Vials with different ink contents must not stack");
helper.succeed();
}
@GameTest(template = "empty")
public static void linkPanelPreparationConsumesOnePageAndConfiguredInkOnlyOnSuccess(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack pages = new ItemStack(Items.PAPER, 2);
ItemStack vial = new ItemStack(ModItems.INK_VIAL.get());
player.setItemInHand(InteractionHand.MAIN_HAND, vial);
player.setItemInHand(InteractionHand.OFF_HAND, pages);
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"Paper and sufficient ink must prepare one link panel");
helper.assertValueEqual(pages.getCount(), 1, "Preparation must consume exactly one paper");
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()),
InkVialItem.CAPACITY - WritingInkService.linkPanelCost(),
"Preparation must consume exactly the server-configured ink cost");
long panels = player.getInventory().items.stream()
.filter(stack -> stack.is(ModItems.PAGE.get())
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL)
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(panels, 1L, "Preparation must output exactly one link panel");
ItemStack written = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
player.setItemInHand(InteractionHand.OFF_HAND, written);
int inkBefore = InkVialItem.remaining(player.getMainHandItem());
helper.assertFalse(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"A written Page must not be overwritten as a link panel");
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()), inkBefore,
"Rejected writing must not consume ink");
helper.assertValueEqual(PageItem.content(written).kind(), PageContent.Kind.SYMBOL,
"Rejected writing must not modify the input Page");
ItemStack lowInk = new ItemStack(ModItems.INK_VIAL.get());
lowInk.set(ModDataComponents.INK_REMAINING.get(), WritingInkService.linkPanelCost() - 1);
player.setItemInHand(InteractionHand.MAIN_HAND, lowInk);
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(Items.PAPER));
helper.assertFalse(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"Insufficient ink must reject link-panel preparation");
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()),
WritingInkService.linkPanelCost() - 1,
"Insufficient ink must remain untouched");
helper.assertTrue(player.getOffhandItem().is(Items.PAPER),
"Insufficient ink must leave paper untouched");
lowInk.set(ModDataComponents.INK_REMAINING.get(), WritingInkService.linkPanelCost());
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"Exact remaining ink must be usable");
helper.assertTrue(player.getMainHandItem().is(Items.GLASS_BOTTLE),
"A depleted Ink Vial must return a reusable glass bottle");
helper.assertValueEqual(PageItem.content(player.getOffhandItem()).kind(),
PageContent.Kind.LINK_PANEL,
"A single paper must be replaced by one Link Panel Page");
helper.succeed();
}
@GameTest(template = "empty")
public static void stackedInkVialsSplitWithoutChangingTheOtherVials(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(ModItems.INK_VIAL.get(), 2));
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(Items.PAPER));
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"A stack of full vials must support preparing one panel");
helper.assertValueEqual(player.getMainHandItem().getCount(), 1,
"Using one vial must leave the other full vial in hand");
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()), InkVialItem.CAPACITY,
"The unconsumed vial must remain full");
long partialVials = player.getInventory().items.stream()
.filter(stack -> stack.is(ModItems.INK_VIAL.get())
&& InkVialItem.remaining(stack) == InkVialItem.CAPACITY - WritingInkService.linkPanelCost())
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(partialVials, 1L,
"The used vial must reappear with only its own ink reduced");
helper.succeed();
}
private static SymbolReference reference(String id) {
return new SymbolReference(ResourceLocation.parse(id));
}
}
@@ -0,0 +1,646 @@
package net.dnorberg.rivencraft.gametest;
import java.util.List;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.item.PageItem;
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.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.inventory.ClickType;
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_desk")
@PrefixGameTestTemplate(false)
public final class WritingDeskGameTests {
private WritingDeskGameTests() {}
@GameTest(template = "empty")
public static void inventoryAndActiveCollectionPersist(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var original = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
original.setItem(0, new ItemStack(ModItems.NOTEBOOK.get()));
original.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
original.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 12));
original.setActiveCollection(2);
var saved = original.saveWithoutMetadata(helper.getLevel().registryAccess());
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
helper.assertTrue(restored.getItem(0).is(ModItems.NOTEBOOK.get()), "Notebook lost on save/load");
helper.assertTrue(restored.getItem(WritingDeskBlockEntity.INK).is(ModItems.INK_VIAL.get()), "Ink lost on save/load");
helper.assertValueEqual(restored.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Pages lost on save/load");
helper.assertValueEqual(restored.activeCollection(), 2, "Active collection lost on save/load");
helper.succeed();
}
@GameTest(template = "empty")
public static void breakingDeskDropsItsStoredItemsOnce(GameTestHelper helper) {
BlockPos relative = new BlockPos(1, 1, 1);
BlockPos pos = helper.absolutePos(relative);
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, new ItemStack(ModItems.NOTEBOOK.get()));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 12));
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(2));
int notebooks = drops.stream().filter(e -> e.getItem().is(ModItems.NOTEBOOK.get())).mapToInt(e -> e.getItem().getCount()).sum();
int bottles = drops.stream().filter(e -> e.getItem().is(Items.GLASS_BOTTLE)).mapToInt(e -> e.getItem().getCount()).sum();
int pages = drops.stream().filter(e -> e.getItem().is(Items.PAPER)).mapToInt(e -> e.getItem().getCount()).sum();
helper.assertValueEqual(notebooks, 1, "Notebook must drop exactly once");
helper.assertValueEqual(bottles, 1, "Consumed vial must return one glass bottle on break");
helper.assertValueEqual(pages, 12, "Pages must drop without loss");
helper.succeed();
}
@GameTest(template = "empty")
public static void menuTransfersWithoutDuplicationAndClosesWhenReplaced(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);
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
player.getInventory().setItem(9, new ItemStack(Items.PAPER, 12));
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertTrue(menu.stillValid(player), "Placed desk must keep its menu valid");
helper.assertTrue(menu.clickMenuButton(player, 3), "Active collection control must accept a valid tab");
helper.assertValueEqual(desk.activeCollection(), 3, "Selected tab must be stored on the desk");
menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT + 1);
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Shift-click must deposit all pages");
helper.assertTrue(player.getInventory().getItem(9).isEmpty(), "Shift-click must consume player source stack");
menu.quickMoveStack(player, WritingDeskBlockEntity.PAPER);
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(), "Shift-click must empty desk source slot");
int total = player.getInventory().items.stream().filter(stack -> stack.is(Items.PAPER))
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(total, 12, "Round-trip must not lose or duplicate pages");
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(menu.stillValid(player), "Removed desk must invalidate menu");
helper.succeed();
}
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);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
ItemStack source = PageItem.createLinkPanelPage();
desk.setItem(0, source.copy());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
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);
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
"A source page with paper and ink must preview one copy");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 2,
"Previewing must not consume paper");
helper.assertValueEqual(desk.inkRemaining(), 16, "Inserting a vial must fill the empty bar before copying");
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ClickType.PICKUP, player);
helper.assertTrue(menu.getCarried().is(ModItems.PAGE.get()), "Taking result must give the copied page");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Taking a copy must consume one paper");
helper.assertValueEqual(desk.inkRemaining(), 15, "First copy must consume one ink");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).isEmpty(), "Refill must consume one vial");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE),
"Refill must return a vanilla glass bottle");
helper.assertTrue(desk.getItem(0).is(ModItems.PAGE.get()), "Source page must remain untouched");
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
"A second copy must remain available from the same ink fill");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
"Two copies must consume exactly two paper");
helper.assertValueEqual(desk.inkRemaining(), 14, "Two copies must spend exactly two of sixteen ink");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.RETURN).getCount(), 1,
"A second copy must not consume another vial");
var saved = desk.saveWithoutMetadata(helper.getLevel().registryAccess());
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
helper.assertValueEqual(restored.inkRemaining(), 14, "Ink meter must persist across saves");
helper.succeed();
}
@GameTest(template = "empty")
public static void emptyDeskInkBarRefillsWithoutCopying(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(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get(), 2));
helper.assertValueEqual(desk.inkRemaining(), 16, "Inserting a vial must refill immediately");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.INK).getCount(), 1,
"Refill must consume exactly one vial");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE),
"Refill must put a vanilla glass bottle in the return slot");
helper.runAfterDelay(2, () -> {
helper.assertValueEqual(desk.inkRemaining(), 16, "Full bar must not refill again");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.INK).getCount(), 1,
"Full bar must leave the second vial untouched");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.RETURN).getCount(), 1,
"Full bar must not create another bottle");
helper.succeed();
});
}
@GameTest(template = "empty")
public static void emptyDeskInkBarWaitsForBottleSpace(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(WritingDeskBlockEntity.RETURN, new ItemStack(Items.GLASS_BOTTLE, 64));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
helper.assertValueEqual(desk.inkRemaining(), 0, "Full bottle slot must block automatic refill");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).is(ModItems.INK_VIAL.get()),
"Blocked refill must preserve the vial");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.RETURN).getCount(), 64,
"Blocked refill must preserve the bottle stack");
desk.removeItem(WritingDeskBlockEntity.RETURN, 1);
helper.assertValueEqual(desk.inkRemaining(), 16, "Removing a bottle must immediately permit refill");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).isEmpty(),
"Refill must consume the waiting vial");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.RETURN).getCount(), 64,
"Refill must return one glass bottle to the available space");
helper.succeed();
}
@GameTest(template = "empty")
public static void shiftClickCopiesOnlyWhenResourcesAndBottleSpaceExist(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, 2));
desk.setItem(WritingDeskBlockEntity.RETURN, new ItemStack(Items.GLASS_BOTTLE, 64));
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.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Full bottle return must block refill without consuming supplies");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 2,
"Blocked copy must leave paper untouched");
desk.removeItem(WritingDeskBlockEntity.RETURN, 1);
ItemStack copied = menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT);
helper.assertTrue(copied.is(ModItems.PAGE.get()), "Shift-click must transfer one copy");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Shift-click must consume one paper");
helper.assertValueEqual(desk.inkRemaining(), 15, "Shift-click must consume one ink");
int copies = player.getInventory().items.stream().filter(stack -> stack.is(ModItems.PAGE.get()))
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(copies, 1, "Shift-click must give exactly one page");
helper.succeed();
}
@GameTest(template = "empty")
public static void selectedNotebookPageControlsCopyPreview(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);
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
new NotebookContents(List.of(ItemStack.EMPTY, PageItem.createLinkPanelPage())));
desk.setItem(0, notebook);
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);
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty(),
"An empty selected position must not create a copy");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.PAGE_BUTTON_BASE + 1), "Second page selection must be accepted");
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
"Selecting the populated page must show a copy");
helper.succeed();
}
@GameTest(template = "empty")
public static void twoViewersCannotTakeTheSameLastSheet(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 first = helper.makeMockServerPlayerInLevel();
var second = helper.makeMockServerPlayerInLevel();
first.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
second.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu firstMenu = new WritingDeskMenu(1, first.getInventory(), desk);
WritingDeskMenu secondMenu = new WritingDeskMenu(2, second.getInventory(), desk);
firstMenu.broadcastChanges();
secondMenu.broadcastChanges();
helper.assertTrue(firstMenu.quickMoveStack(first, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
"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));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
ResourceLocation known = ResourceLocation.parse("rivencraft:terrain/standard");
ItemStack page = PageItem.createSymbolPage(new SymbolReference(known));
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(page.copy())));
desk.setItem(0, page);
desk.setItem(1, notebook);
helper.assertValueEqual(WritingDeskCatalog.available(desk).size(), 1,
"Duplicate symbols across loose pages and notebooks must appear once");
helper.assertValueEqual(WritingDeskCatalog.available(desk).getFirst().id(), known,
"Catalog must retain the authoritative symbol ID");
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 + 1), "Out-of-range catalog index must be rejected");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.CATALOG_BUTTON_BASE), "Known catalog symbol must be selectable");
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
"Catalog selection must preview a copy of the known symbol");
desk.setItem(0, ItemStack.EMPTY);
desk.setItem(1, ItemStack.EMPTY);
menu.broadcastChanges();
helper.assertTrue(menu.catalog().isEmpty(), "Removed sources must invalidate catalog for the viewer");
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty(),
"Removed sources must invalidate the copy output");
helper.succeed();
}
@GameTest(template = "empty")
public static void fullInventoryCannotConsumeCopyMaterials(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);
for (int i = 0; i < 36; i++) player.getInventory().setItem(i, new ItemStack(Items.COBBLESTONE, 64));
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Full inventory must reject shift-click copy");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Rejected copy must preserve paper");
helper.assertValueEqual(desk.inkRemaining(), 16, "Rejected copy must preserve the filled ink meter");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).isEmpty(),
"The vial should already have refilled the empty meter");
helper.succeed();
}
public static void twoBlockDeskSharesOneInventoryAndBreaksTogether(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var block = ModBlocks.WRITING_DESK.get();
var state = block.defaultBlockState().setValue(WritingDeskBlock.FACING, Direction.NORTH);
helper.getLevel().setBlock(pos, state, 3);
block.setPlacedBy(helper.getLevel(), pos, state, null, new ItemStack(block));
BlockPos extension = pos.relative(Direction.EAST);
helper.assertTrue(helper.getLevel().getBlockState(extension).is(block),
"Placing a desk must reserve the adjacent second block");
helper.assertTrue(helper.getLevel().getBlockState(extension).getValue(WritingDeskBlock.EXTENSION),
"Adjacent half must be marked as an extension");
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
helper.assertTrue(helper.getLevel().getBlockEntity(extension) == null,
"Extension must not duplicate the inventory");
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 7));
helper.getLevel().destroyBlock(extension, true);
helper.assertTrue(helper.getLevel().getBlockState(pos).isAir(),
"Breaking the extension must remove the controller");
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(3));
int desks = drops.stream().filter(e -> e.getItem().is(block.asItem()))
.mapToInt(e -> e.getItem().getCount()).sum();
int paper = drops.stream().filter(e -> e.getItem().is(Items.PAPER))
.mapToInt(e -> e.getItem().getCount()).sum();
helper.assertValueEqual(desks, 1, "Breaking either half must drop one desk");
helper.assertValueEqual(paper, 7, "Breaking either half must drop stored paper once");
helper.succeed();
}
@GameTest(template = "empty")
public static void breakingControllerRemovesExtensionWithoutDuplicateDesk(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var block = ModBlocks.WRITING_DESK.get();
var state = block.defaultBlockState().setValue(WritingDeskBlock.FACING, Direction.NORTH);
helper.getLevel().setBlock(pos, state, 3);
block.setPlacedBy(helper.getLevel(), pos, state, null, new ItemStack(block));
BlockPos extension = pos.relative(Direction.EAST);
helper.getLevel().destroyBlock(pos, true);
helper.assertTrue(helper.getLevel().getBlockState(extension).isAir(),
"Breaking the controller must remove the extension");
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(3));
int desks = drops.stream().filter(e -> e.getItem().is(block.asItem()))
.mapToInt(e -> e.getItem().getCount()).sum();
helper.assertValueEqual(desks, 1, "Controller break must drop exactly one desk");
helper.succeed();
}
@GameTest(template = "empty")
public static void openDeskDoesNotOccludeTerrain(GameTestHelper helper) {
helper.assertFalse(ModBlocks.WRITING_DESK.get().defaultBlockState().canOcclude(),
"Open shelves must not cull terrain behind the desk");
helper.succeed();
}
@GameTest(template = "empty")
public static void allPagesSelectionUsesTheRequestedPhysicalTab(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);
ItemStack first = PageItem.createLinkPanelPage();
ItemStack second = PageItem.createSymbolPage(new SymbolReference(
ResourceLocation.parse("rivencraft:terrain/standard")));
desk.setItem(0, first);
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
new NotebookContents(List.of(ItemStack.EMPTY, second)));
desk.setItem(1, notebook);
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.ALL_PAGE_BUTTON_BASE + 2 * NotebookContents.MAX_PAGES),
"ALL view must reject empty cross-tab positions");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + NotebookContents.MAX_PAGES + 1),
"ALL view must accept the populated second tab page");
helper.assertTrue(ItemStack.matches(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem(), second),
"Copy preview must match the physical page selected from the other tab");
helper.assertValueEqual(desk.activeCollection(), 0,
"Selecting from ALL must not silently change the active tab");
helper.succeed();
}
@GameTest(template = "empty")
public static void tabGroupsCycleByFourAndProtectHiddenSlots(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
var otherViewer = new WritingDeskMenu(2, player.getInventory(), desk);
menu.clickMenuButton(player, WritingDeskMenu.PREVIOUS_TABS);
helper.assertValueEqual(menu.firstVisibleTab(), 0, "Previous must stop at tabs 1-4");
menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
helper.assertValueEqual(menu.firstVisibleTab(), 4, "Next must show tabs 5-8");
helper.assertValueEqual(otherViewer.firstVisibleTab(), 0, "Cycling must not move another viewer's slots");
helper.assertFalse(menu.getSlot(0).isActive(), "Earlier tab slots must be hidden");
helper.assertTrue(menu.getSlot(4).isActive(), "New group's slots must be interactive");
desk.setItem(0, new ItemStack(ModItems.NOTEBOOK.get()));
menu.clicked(0, 0, ClickType.PICKUP, player);
helper.assertTrue(menu.getCarried().isEmpty(), "Hidden slot clicks must not take items");
helper.assertTrue(menu.quickMoveStack(player, 0).isEmpty(), "Hidden shift-click must be rejected");
player.getInventory().setItem(9, new ItemStack(ModItems.NOTEBOOK.get()));
menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT + 1);
helper.assertTrue(desk.getItem(4).is(ModItems.NOTEBOOK.get()), "Shift-click must use the visible group");
for (int i = 0; i < 30; i++) menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
helper.assertValueEqual(menu.firstVisibleTab(), 84, "Last group must be tabs 85-88");
helper.assertTrue(menu.clickMenuButton(player, 87), "Tab 88 must be selectable without page-ID collisions");
helper.assertValueEqual(desk.activeCollection(), 87, "Tab 88 must become active");
helper.assertFalse(menu.clickMenuButton(player, 88), "Tab 89 must not exist");
desk.setItem(87, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + 87 * NotebookContents.MAX_PAGES),
"ALL must select a physical page from tab 88");
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
"Tab 88's page must be copyable");
menu.clickMenuButton(player, WritingDeskMenu.PREVIOUS_TABS);
helper.assertValueEqual(menu.firstVisibleTab(), 80, "Previous must move back four tabs");
helper.succeed();
}
public static void oldDeskInventoryMigratesAndAll88TabsPersist(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var registries = helper.getLevel().registryAccess();
var oldItems = net.minecraft.core.NonNullList.withSize(8, ItemStack.EMPTY);
oldItems.set(0, new ItemStack(ModItems.NOTEBOOK.get()));
oldItems.set(3, PageItem.createLinkPanelPage());
oldItems.set(4, new ItemStack(ModItems.INK_VIAL.get(), 2));
oldItems.set(5, new ItemStack(Items.GLASS_BOTTLE, 3));
oldItems.set(6, new ItemStack(Items.PAPER, 12));
oldItems.set(7, PageItem.createLinkPanelPage());
var oldTag = new net.minecraft.nbt.CompoundTag();
net.minecraft.world.ContainerHelper.saveAllItems(oldTag, oldItems, registries);
oldTag.putInt("InkRemaining", 7);
oldTag.putInt("ActiveCollection", 3);
var desk = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
desk.loadWithComponents(oldTag, registries);
for (int i = 0; i < 4; i++) {
helper.assertTrue(ItemStack.matches(desk.getItem(i), oldItems.get(i)), "Original tab must survive migration");
helper.assertTrue(ItemStack.matches(desk.getItem(WritingDeskBlockEntity.INK + i), oldItems.get(4 + i)),
"Original supplies and legacy output must survive migration");
helper.assertTrue(desk.getItem(4 + i).isEmpty(), "Supplies must not be duplicated into new tabs");
}
helper.assertValueEqual(desk.inkRemaining(), 7, "Migration must preserve ink meter");
helper.assertValueEqual(desk.activeCollection(), 3, "Migration must preserve active tab");
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) desk.setItem(i, PageItem.createLinkPanelPage());
desk.setActiveCollection(87);
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
restored.loadWithComponents(desk.saveWithoutMetadata(registries), registries);
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++)
helper.assertTrue(restored.getItem(i).is(ModItems.PAGE.get()), "All 88 tab contents must survive save/load");
helper.assertValueEqual(restored.activeCollection(), 87, "Tab 88 selection must survive save/load");
helper.assertValueEqual(restored.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Reload must not repeat migration");
helper.succeed();
}
@GameTest(template = "empty")
public static void surfacePlacesCarriedPagesWithoutLossOrOverwrite(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(87, new ItemStack(ModItems.NOTEBOOK.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
ItemStack held = PageItem.createLinkPanelPage().copyWithCount(3);
ItemStack expectedPage = held.copyWithCount(1);
player.getInventory().setItem(9, held);
menu.clicked(WritingDeskMenu.COPY_RESULT + 1, 0, ClickType.PICKUP, player);
int lastPosition = WritingDeskMenu.PLACE_PAGE_BUTTON_BASE + 87 * NotebookContents.MAX_PAGES + 26;
helper.assertTrue(menu.clickMenuButton(player, lastPosition), "Surface must place into final notebook position");
helper.assertValueEqual(menu.getCarried().getCount(), 2, "Placing must consume exactly one held page");
var pages = desk.getItem(87).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages();
helper.assertValueEqual(pages.size(), 27, "Placement must preserve addressed position");
helper.assertTrue(pages.get(0).isEmpty() && pages.get(25).isEmpty(), "Placement must preserve preceding gaps");
helper.assertTrue(ItemStack.matches(pages.get(26), expectedPage), "Stored page must preserve components");
helper.assertFalse(menu.clickMenuButton(player, lastPosition), "Occupied position must not be overwritten");
helper.assertValueEqual(menu.getCarried().getCount(), 2, "Rejected placement must preserve cursor");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.PLACE_PAGE_BUTTON_BASE + 88 * NotebookContents.MAX_PAGES),
"Out-of-range placement must be rejected");
helper.assertValueEqual(menu.pageCapacity(87), NotebookContents.MAX_PAGES, "Notebook tab must expose normal notebook capacity");
helper.assertValueEqual(menu.pageCapacity(0), 0, "Empty tab has no page storage");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.PLACE_PAGE_BUTTON_BASE), "Empty tab must require a collection first");
desk.setItem(0, PageItem.createLinkPanelPage());
helper.assertValueEqual(menu.pageCapacity(0), 1, "Loose page tab must expose exactly one position");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.PLACE_PAGE_BUTTON_BASE), "Loose page must not be replaced");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.PLACE_PAGE_BUTTON_BASE + 1), "Loose page tab must not grow extra positions");
helper.assertValueEqual(menu.getCarried().getCount(), 2, "Rejected placements must preserve cursor");
helper.assertTrue(menu.clickMenuButton(player, lastPosition - 1), "A second empty notebook position must accept a page");
helper.assertTrue(menu.clickMenuButton(player, lastPosition - 2), "A third empty notebook position must accept the last page");
helper.assertTrue(menu.getCarried().isEmpty(), "Final placement must clear cursor");
menu.setCarried(new ItemStack(Items.DIAMOND));
helper.assertFalse(menu.clickMenuButton(player, lastPosition - 3), "Non-page items must be rejected");
helper.assertTrue(menu.getCarried().is(Items.DIAMOND), "Rejected item must remain held");
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
restored.loadWithComponents(desk.saveWithoutMetadata(helper.getLevel().registryAccess()), helper.getLevel().registryAccess());
helper.assertValueEqual(restored.getItem(87).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pageCount(), 3,
"Placed pages must persist in the notebook");
helper.succeed();
}
@GameTest(template = "empty")
public static void deskSurfaceDragMovesAndSwapsNotebookPages(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
ItemStack linkPanel = PageItem.createLinkPanelPage();
ItemStack terrain = PageItem.createSymbolPage(new SymbolReference(
ResourceLocation.parse("rivencraft:terrain/standard")));
ItemStack firstBook = new ItemStack(ModItems.NOTEBOOK.get());
firstBook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(linkPanel, terrain)));
desk.setItem(0, firstBook);
desk.setItem(1, new ItemStack(ModItems.NOTEBOOK.get()));
desk.setItem(2, PageItem.createLinkPanelPage());
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
int moveToEmpty = WritingDeskMenu.movePageButtonId(0, 0, 0, 26);
helper.assertTrue(menu.clickMenuButton(player, moveToEmpty), "Drag must move to a distant empty notebook position");
var pages = desk.getItem(0).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages();
helper.assertTrue(pages.get(0).isEmpty(), "Moving must clear the source position");
helper.assertTrue(ItemStack.matches(pages.get(26), linkPanel), "Moving must preserve page components");
helper.assertValueEqual(desk.getItem(0).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pageCount(), 2,
"Moving must preserve total page count");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(0, 26, 0, 1)),
"Drag onto an occupied position must swap pages");
pages = desk.getItem(0).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages();
helper.assertTrue(ItemStack.matches(pages.get(1), linkPanel) && ItemStack.matches(pages.get(26), terrain),
"Swap must preserve both pages");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(0, 1, 1, 5)),
"Drag into another notebook must work");
var otherPages = desk.getItem(1).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages();
helper.assertTrue(ItemStack.matches(otherPages.get(5), linkPanel)
&& desk.getItem(0).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages().get(1).isEmpty(),
"Cross notebook move must have one destination and no old source");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(0, 1, 1, 6)),
"Dragging an empty source must be rejected");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(2, 0, 1, 6)),
"A loose page should move into a notebook position");
helper.assertTrue(desk.getItem(2).isEmpty()
&& desk.getItem(1).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages().get(6).is(ModItems.PAGE.get()),
"Moving a loose page must empty its tab without losing the item");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(1, 5, 1, 5)),
"A no-op drag must be rejected");
menu.setCarried(new ItemStack(Items.DIAMOND));
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(1, 5, 1, 6)),
"Held inventory items must not be moved by a panel drag");
helper.assertTrue(ItemStack.matches(desk.getItem(1).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages().get(5), linkPanel),
"Rejected drags must leave notebook data intact");
helper.succeed();
}
@GameTest(template = "empty")
public static void deskSurfaceDragExtractsPagesToInventoryAndTabs(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
ItemStack page = PageItem.createLinkPanelPage();
ItemStack source = new ItemStack(ModItems.NOTEBOOK.get());
source.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
new NotebookContents(List.of(page, page, page, page, page, page)));
desk.setItem(0, source);
ItemStack otherBook = new ItemStack(ModItems.NOTEBOOK.get());
ItemStack terrain = PageItem.createSymbolPage(new SymbolReference(
ResourceLocation.parse("rivencraft:terrain/standard")));
otherBook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(terrain)));
desk.setItem(2, otherBook);
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
player.getInventory().setItem(10, page.copyWithCount(2));
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
int inventoryStart = WritingDeskMenu.COPY_RESULT + 1;
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 0, -1)),
"Dropping away from a slot must return the page to the cursor");
helper.assertTrue(ItemStack.matches(menu.getCarried(), page), "Cursor must hold the original page item");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 1, inventoryStart)),
"Extraction must not replace an occupied cursor");
menu.setCarried(ItemStack.EMPTY);
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 1, inventoryStart)),
"Dropping on player inventory must transfer a page");
helper.assertTrue(ItemStack.matches(player.getInventory().getItem(9), page),
"Transferred page must retain its components");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 2, 1)),
"Dropping on an empty tab must store a loose page");
helper.assertTrue(ItemStack.matches(desk.getItem(1), page), "Tab must hold the extracted page");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 3, 2)),
"Dropping on a notebook tab must insert the page");
var otherPages = desk.getItem(2).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages();
helper.assertTrue(ItemStack.matches(otherPages.get(0), terrain)
&& ItemStack.matches(otherPages.get(1), page), "Notebook drop must preserve existing pages");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 4, inventoryStart + 1)),
"Dropping on a matching inventory stack must merge one page");
helper.assertValueEqual(player.getInventory().getItem(10).getCount(), 3, "Merge must add exactly one page");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 5, 1)),
"Occupied loose-page tabs must reject a drop");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 5, WritingDeskBlockEntity.INK)),
"Ink slot must not accept an extracted page");
helper.assertValueEqual(desk.getItem(0).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pageCount(), 1,
"Rejected drops must leave the remaining source page in the notebook");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(1, 0, -1)),
"A loose page shown on the panel must be draggable back to the cursor");
helper.assertTrue(desk.getItem(1).isEmpty() && ItemStack.matches(menu.getCarried(), page),
"Extracting a loose page must empty its tab and return the same item");
helper.succeed();
}
}
@@ -0,0 +1,143 @@
package net.dnorberg.rivencraft.gametest;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDefinition;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Issue #30: parsing must not mutate writing or trust client display metadata. */
@GameTestHolder("rivencraft_archive_grammar")
@PrefixGameTestTemplate(false)
public final class WritingGrammarGameTests {
private WritingGrammarGameTests() {}
@GameTest(template = "empty")
public static void orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences(GameTestHelper helper) {
SymbolReference terrain = ref("rivencraft:terrain/standard");
SymbolReference nativeBiomes = ref("rivencraft:biome/native");
SymbolReference weather = ref("rivencraft:weather/normal");
SymbolReference lighting = ref("rivencraft:lighting/normal");
SymbolReference celestial = ref("rivencraft:celestial/normal_time");
SymbolReference caves = ref("rivencraft:feature/caves");
SymbolReference plains = ref("test:biome/plains");
SymbolReference forest = ref("test:biome/forest");
SymbolReference single = ref("test:controller/single");
SymbolReference alternateTerrain = ref("test:terrain/alternate");
SymbolReference periodHalf = ref("test:period/half");
SymbolReference periodFull = ref("test:period/full");
SymbolReference displayOnly = ref("test:display_only");
Map<SymbolReference, SymbolDefinition> definitions = Map.of(
plains, definition(SymbolSemantics.Role.BIOME,
new SymbolSemantics.ResourceValue(ResourceLocation.parse("minecraft:plains"))),
forest, definition(SymbolSemantics.Role.BIOME,
new SymbolSemantics.ResourceValue(ResourceLocation.parse("minecraft:forest"))),
single, definition(SymbolSemantics.Role.BIOME_CONTROLLER,
new SymbolSemantics.ChoiceValue("single")),
alternateTerrain, definition(SymbolSemantics.Role.TERRAIN,
new SymbolSemantics.ResourceValue(ResourceLocation.parse("test:alternate"))),
periodHalf, definition(SymbolSemantics.Role.PERIOD_MODIFIER,
new SymbolSemantics.NumberValue(0.5)),
periodFull, definition(SymbolSemantics.Role.PERIOD_MODIFIER,
new SymbolSemantics.NumberValue(1.0)),
displayOnly, new SymbolDefinition(1, "symbol.test", "symbol.test.description",
ResourceLocation.parse("test:display"), Optional.empty(), ""));
java.util.function.Function<SymbolReference, Optional<SymbolDefinition>> resolver = reference ->
Optional.ofNullable(definitions.get(reference)).or(() -> SymbolCatalog.find(reference));
helper.assertTrue(parse(resolver, WritingEntry.linkPanel()).randomFallback(),
"Link-Panel-only writing must retain the random Age path");
helper.assertTrue(WritingGrammar.parse(OrderedWriting.EMPTY, resolver).randomFallback(),
"Old page-free books must retain the random Age path");
OrderedWriting partial = writing(WritingEntry.linkPanel(), WritingEntry.symbol(terrain));
WritingGrammar.Result incomplete = WritingGrammar.parse(partial, resolver);
helper.assertTrue(incomplete.canCreate() && !incomplete.randomFallback(),
"Partial authored writing must remain valid, without pretending it is page-free");
helper.assertTrue(incomplete.diagnostics().stream().anyMatch(d -> d.code() == WritingGrammar.Code.INCOMPLETE),
"Missing branches must be reported for deterministic completion");
helper.assertValueEqual(partial.entries().size(), 2, "Parsing must not rewrite saved page order");
WritingGrammar.Result complete = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
WritingEntry.symbol(plains), WritingEntry.symbol(single), WritingEntry.symbol(weather),
WritingEntry.symbol(lighting), WritingEntry.symbol(periodHalf), WritingEntry.symbol(celestial),
WritingEntry.symbol(caves));
helper.assertTrue(complete.canCreate(), "Legacy-ordered categories and prefix modifiers must parse");
helper.assertValueEqual(complete.symbols().size(), 8, "Parsed symbols must preserve every authored page");
helper.assertValueEqual(complete.symbols().get(1).reference(), plains,
"A biome page must precede the controller that consumes it");
helper.assertValueEqual(WritingGrammar.parse(writing(WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
WritingEntry.symbol(plains), WritingEntry.symbol(single), WritingEntry.symbol(weather),
WritingEntry.symbol(lighting), WritingEntry.symbol(periodHalf), WritingEntry.symbol(celestial),
WritingEntry.symbol(caves)), resolver), complete,
"Identical ordered writing and server definitions must parse deterministically");
WritingGrammar.Result repeatedModifiers = parse(resolver, WritingEntry.linkPanel(),
WritingEntry.symbol(periodHalf), WritingEntry.symbol(periodFull), WritingEntry.symbol(celestial));
helper.assertTrue(repeatedModifiers.canCreate(), "Repeated period modifiers must remain legal");
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
WritingEntry.symbol(terrain)), WritingGrammar.Code.DUPLICATE);
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
WritingEntry.symbol(alternateTerrain)), WritingGrammar.Code.CONFLICT);
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(weather),
WritingEntry.symbol(terrain)), WritingGrammar.Code.OUT_OF_ORDER);
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial),
WritingEntry.symbol(periodHalf)), WritingGrammar.Code.MISPLACED_MODIFIER);
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(nativeBiomes),
WritingEntry.symbol(plains)), WritingGrammar.Code.OUT_OF_ORDER);
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains),
WritingEntry.symbol(nativeBiomes)), WritingGrammar.Code.CONFLICT);
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains),
WritingEntry.symbol(forest), WritingEntry.symbol(single)), WritingGrammar.Code.CONFLICT);
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(caves),
WritingEntry.symbol(caves)), WritingGrammar.Code.DUPLICATE);
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(ref("test:missing"))),
WritingGrammar.Code.UNRESOLVED);
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(displayOnly)),
WritingGrammar.Code.UNSUPPORTED);
assertCode(helper, parse(resolver, WritingEntry.symbol(terrain)), WritingGrammar.Code.MISSING_PANEL);
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.linkPanel()),
WritingGrammar.Code.DUPLICATE_PANEL);
List<WritingEntry> oversized = new ArrayList<>();
for (int index = 0; index <= WritingGrammar.MAX_NEW_ENTRIES; index++)
oversized.add(WritingEntry.linkPanel());
assertCode(helper, WritingGrammar.parse(new OrderedWriting(oversized), resolver), WritingGrammar.Code.TOO_MANY);
helper.succeed();
}
private static SymbolReference ref(String id) {
return new SymbolReference(ResourceLocation.parse(id));
}
private static SymbolDefinition definition(SymbolSemantics.Role role, SymbolSemantics.Value value) {
return new SymbolDefinition(2, "symbol.test", "symbol.test.description",
ResourceLocation.parse("test:" + role.id()), Optional.of(new SymbolSemantics(role, value)), "");
}
private static OrderedWriting writing(WritingEntry... entries) {
return new OrderedWriting(List.of(entries));
}
private static WritingGrammar.Result parse(
java.util.function.Function<SymbolReference, Optional<SymbolDefinition>> resolver,
WritingEntry... entries) {
return WritingGrammar.parse(writing(entries), resolver);
}
private static void assertCode(GameTestHelper helper, WritingGrammar.Result result, WritingGrammar.Code code) {
helper.assertFalse(result.canCreate(), "Writing with " + code + " must not create a new Age");
helper.assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.code() == code),
"Writing must report " + code + " rather than silently dropping a page");
}
}
@@ -0,0 +1,47 @@
package net.dnorberg.rivencraft.item;
import java.util.List;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
/** A stackable vial; the ink component is per vial, not shared across a stack. */
public final class InkVialItem extends Item {
public static final int CAPACITY = 16;
public InkVialItem(Properties properties) {
super(properties);
}
public static int remaining(ItemStack vial) {
return vial.getOrDefault(ModDataComponents.INK_REMAINING.get(), CAPACITY);
}
@Override
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
if (!WritingInkService.canPrepareLinkPanel(player, hand)) {
return InteractionResultHolder.pass(player.getItemInHand(hand));
}
if (!level.isClientSide) {
WritingInkService.prepareLinkPanel(player, hand);
}
return InteractionResultHolder.sidedSuccess(player.getItemInHand(hand), level.isClientSide);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context,
List<Component> tooltip, TooltipFlag flag) {
tooltip.add(Component.translatable("item.rivencraft.ink_vial.remaining",
remaining(stack), CAPACITY).withStyle(ChatFormatting.GRAY));
tooltip.add(Component.translatable("item.rivencraft.ink_vial.usage")
.withStyle(ChatFormatting.GRAY));
}
}
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.item;
import java.util.List;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
public final class NotebookItem extends Item {
public NotebookItem(Properties properties) {
super(properties);
}
@Override
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
ItemStack notebook = player.getItemInHand(hand);
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer) {
serverPlayer.openMenu(new SimpleMenuProvider(
(containerId, inventory, ignored) -> new NotebookMenu(containerId, inventory, hand),
notebook.getHoverName()),
buffer -> buffer.writeBoolean(hand == InteractionHand.OFF_HAND));
}
return InteractionResultHolder.sidedSuccess(notebook, level.isClientSide);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context,
List<Component> tooltip, TooltipFlag flag) {
NotebookContents contents = stack.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
tooltip.add(Component.translatable("item.rivencraft.notebook.pages",
contents.pageCount(), NotebookContents.MAX_PAGES).withStyle(ChatFormatting.GRAY));
if (contents.unresolvedCount() > 0) {
tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved",
contents.unresolvedCount()).withStyle(ChatFormatting.RED));
}
}
}
@@ -0,0 +1,136 @@
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;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.SymbolPoems;
import net.minecraft.ChatFormatting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
/** One stackable Page item whose server-owned component identifies its writing state. */
public final class PageItem extends Item {
public PageItem(Properties properties) {
super(properties);
}
public static ItemStack createSymbolPage(SymbolReference reference) {
if (!SymbolCatalog.isKnown(reference)) {
throw new IllegalArgumentException("Cannot create a page for unknown symbol " + reference.id());
}
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, SymbolPoems.builtIn(reference.id())));
return page;
}
public static ItemStack createLinkPanelPage() {
return create(PageContent.LINK_PANEL);
}
/** Restores saved unknown references without granting them validity for new writing operations. */
public static ItemStack createRecoveredPage(SymbolReference reference) {
return create(PageContent.unresolved(reference));
}
public static PageContent content(ItemStack stack) {
PageContent content = stack.get(ModDataComponents.PAGE_CONTENT.get());
return content == null ? PageContent.BLANK : content;
}
/** Server-side gate for Desk and Binder inputs; unresolved pages remain removable but not usable. */
public static boolean isUsableWriting(ItemStack stack) {
if (!stack.is(ModItems.PAGE.get())) {
return false;
}
PageContent resolved = content(stack).resolve();
return resolved.kind() == PageContent.Kind.BLANK
|| resolved.kind() == PageContent.Kind.LINK_PANEL
|| resolved.isResolvedSymbol();
}
@Override
public Component getName(ItemStack stack) {
Component customName = stack.get(DataComponents.CUSTOM_NAME);
if (customName != null) {
return customName;
}
PageContent content = content(stack);
return switch (content.kind()) {
case BLANK -> Component.translatable("item.rivencraft.page.blank");
case LINK_PANEL -> Component.translatable("item.rivencraft.page.link_panel");
case UNRESOLVED_SYMBOL -> Component.translatable("item.rivencraft.page.unresolved");
case SYMBOL -> symbolName(stack, content.symbol().orElseThrow());
};
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context,
List<Component> tooltipComponents, TooltipFlag tooltipFlag) {
PageContent content = content(stack);
if (content.kind() == PageContent.Kind.BLANK) {
tooltipComponents.add(Component.translatable("item.rivencraft.page.blank.description")
.withStyle(ChatFormatting.GRAY));
return;
}
if (content.kind() == PageContent.Kind.LINK_PANEL) {
tooltipComponents.add(Component.translatable("item.rivencraft.page.link_panel.description")
.withStyle(ChatFormatting.GRAY));
return;
}
SymbolReference reference = content.symbol().orElseThrow();
SymbolDisplayMetadata metadata = content.kind() == PageContent.Kind.SYMBOL
? stack.getOrDefault(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), SymbolCatalog.display(reference))
: SymbolDisplayMetadata.unresolved(reference);
if (metadata.resolved()) {
tooltipComponents.add(Component.translatable(metadata.descriptionTranslationKey())
.withStyle(ChatFormatting.GRAY));
} else {
tooltipComponents.add(Component.translatable(
"symbol.rivencraft.unresolved.description", reference.unresolvedName())
.withStyle(ChatFormatting.RED));
}
tooltipComponents.add(Component.literal(reference.id().toString()).withStyle(ChatFormatting.DARK_GRAY));
}
private static ItemStack create(PageContent content) {
ItemStack stack = new ItemStack(ModItems.PAGE.get());
stack.set(ModDataComponents.PAGE_CONTENT.get(), content);
if (content.kind() == PageContent.Kind.SYMBOL) {
stack.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
SymbolCatalog.display(content.symbol().orElseThrow()));
}
return stack;
}
private static Component symbolName(ItemStack stack, SymbolReference reference) {
SymbolDisplayMetadata metadata = stack.getOrDefault(
ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), SymbolCatalog.display(reference));
return metadata.resolved()
? Component.translatable(metadata.nameTranslationKey())
: Component.translatable("item.rivencraft.page.unresolved");
}
}
@@ -0,0 +1,248 @@
package net.dnorberg.rivencraft.menu;
import java.util.Optional;
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.dnorberg.rivencraft.writing.WritingGrammar;
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;
private int grammarErrorCode = -1;
private int grammarErrorStripIndex = -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; }
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarErrorCode : binder.writingGrammar().firstError()
.map(error -> error.code().ordinal()).orElse(-1);
}
@Override public void set(int value) { grammarErrorCode = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarErrorStripIndex : binder.writingGrammar().firstError()
.map(error -> binder.writingStripIndex(error.index())).orElse(-1);
}
@Override public void set(int value) { grammarErrorStripIndex = 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 Optional<WritingGrammar.Code> grammarError() {
WritingGrammar.Code[] codes = WritingGrammar.Code.values();
return grammarErrorCode >= 0 && grammarErrorCode < codes.length
? Optional.of(codes[grammarErrorCode]) : Optional.empty();
}
public int grammarErrorStripIndex() { return grammarErrorStripIndex; }
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);
}
}
}
@@ -0,0 +1,194 @@
package net.dnorberg.rivencraft.menu;
import java.util.List;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.NonNullList;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
public final class NotebookMenu extends AbstractContainerMenu {
public static final int NOTEBOOK_SLOTS = NotebookContents.MAX_PAGES;
public static final int MOVE_PREVIOUS_BASE = 0;
public static final int MOVE_NEXT_BASE = NOTEBOOK_SLOTS;
private static final int PLAYER_INVENTORY_START = NOTEBOOK_SLOTS;
private final Inventory playerInventory;
private final InteractionHand hand;
private final int openedHotbarSlot;
private final NotebookContainer contents;
public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) {
this(containerId, inventory, data.readBoolean() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND);
}
public NotebookMenu(int containerId, Inventory inventory, InteractionHand hand) {
super(ModMenus.NOTEBOOK.get(), containerId);
this.playerInventory = inventory;
this.hand = hand;
this.openedHotbarSlot = inventory.selected;
this.contents = new NotebookContainer(inventory, hand);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
// The client draws these pages on a filtered, scrollable surface instead.
addSlot(new PageSlot(contents, column + row * 9, -1000, -1000));
}
}
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
int inventoryIndex = column + row * 9 + 9;
addSlot(playerSlot(inventoryIndex, 8 + column * 18, 135 + row * 18));
}
}
for (int column = 0; column < 9; column++) {
addSlot(playerSlot(column, 8 + column * 18, 193));
}
}
private Slot playerSlot(int inventoryIndex, int x, int y) {
boolean locked = hand == InteractionHand.MAIN_HAND && inventoryIndex == openedHotbarSlot;
return locked ? new LockedSlot(playerInventory, inventoryIndex, x, y)
: new Slot(playerInventory, inventoryIndex, x, y);
}
@Override
public boolean stillValid(Player player) {
return player.isAlive()
&& (hand == InteractionHand.OFF_HAND || player.getInventory().selected == openedHotbarSlot)
&& player.getItemInHand(hand).is(ModItems.NOTEBOOK.get());
}
@Override
public ItemStack quickMoveStack(Player player, int index) {
Slot slot = slots.get(index);
if (!slot.hasItem()) {
return ItemStack.EMPTY;
}
ItemStack source = slot.getItem();
ItemStack original = source.copy();
if (index < PLAYER_INVENTORY_START) {
if (!moveItemStackTo(source, PLAYER_INVENTORY_START, slots.size(), true)) {
return ItemStack.EMPTY;
}
} else {
if (!source.is(ModItems.PAGE.get())
|| !moveItemStackTo(source, 0, PLAYER_INVENTORY_START, false)) {
return ItemStack.EMPTY;
}
}
if (source.isEmpty()) {
slot.setByPlayer(ItemStack.EMPTY);
} else {
slot.setChanged();
}
return original;
}
@Override
public boolean clickMenuButton(Player player, int id) {
int source;
int target;
if (id >= MOVE_PREVIOUS_BASE && id < MOVE_NEXT_BASE) {
source = id;
target = source - 1;
} else if (id >= MOVE_NEXT_BASE && id < MOVE_NEXT_BASE + NOTEBOOK_SLOTS) {
source = id - MOVE_NEXT_BASE;
target = source + 1;
} else {
return false;
}
if (source < 0 || source >= NOTEBOOK_SLOTS || target < 0 || target >= NOTEBOOK_SLOTS
|| contents.getItem(source).isEmpty()) {
return false;
}
contents.swap(source, target);
broadcastChanges();
return true;
}
@Override
public void clicked(int slotId, int button, ClickType clickType, Player player) {
if (!stillValid(player)) {
player.closeContainer();
return;
}
if (clickType == ClickType.SWAP
&& (button == openedHotbarSlot && hand == InteractionHand.MAIN_HAND
|| button == 40 && hand == InteractionHand.OFF_HAND)) {
return;
}
super.clicked(slotId, button, clickType, player);
}
private static final class PageSlot extends Slot {
private PageSlot(Container container, int slot, int x, int y) {
super(container, slot, x, y);
}
@Override public boolean mayPlace(ItemStack stack) { return stack.is(ModItems.PAGE.get()); }
@Override public int getMaxStackSize() { return 1; }
}
private static final class LockedSlot extends Slot {
private LockedSlot(Container container, int slot, int x, int y) {
super(container, slot, x, y);
}
@Override public boolean mayPickup(Player player) { return false; }
@Override public boolean mayPlace(ItemStack stack) { return false; }
}
private static final class NotebookContainer extends SimpleContainer {
private final Inventory inventory;
private final InteractionHand hand;
private boolean loading;
private NotebookContainer(Inventory inventory, InteractionHand hand) {
super(NOTEBOOK_SLOTS);
this.inventory = inventory;
this.hand = hand;
ItemStack notebook = inventory.player.getItemInHand(hand);
loading = true;
List<ItemStack> saved = notebook.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
for (int index = 0; index < saved.size(); index++) {
super.setItem(index, saved.get(index).copy());
}
loading = false;
}
private void swap(int first, int second) {
loading = true;
ItemStack held = getItem(first);
setItem(first, getItem(second));
setItem(second, held);
loading = false;
setChanged();
}
@Override
public void setChanged() {
super.setChanged();
ItemStack notebook = inventory.player.getItemInHand(hand);
if (loading || !notebook.is(ModItems.NOTEBOOK.get())) {
return;
}
NonNullList<ItemStack> saved = NonNullList.withSize(NOTEBOOK_SLOTS, ItemStack.EMPTY);
for (int index = 0; index < NOTEBOOK_SLOTS; index++) {
ItemStack page = getItem(index);
saved.set(index, page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1));
}
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(saved));
}
}
}
@@ -0,0 +1,448 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.network.PacketDistributor;
import java.util.List;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.DataSlot;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
public final class WritingDeskMenu extends AbstractContainerMenu {
public static final int COPY_RESULT = WritingDeskBlockEntity.SIZE;
public static final int VISIBLE_TABS = 4;
public static final int PREVIOUS_TABS = 90;
public static final int NEXT_TABS = 91;
public static final int PAGE_BUTTON_BASE = 128;
public static final int CATALOG_BUTTON_BASE = 512;
public static final int ALL_PAGE_BUTTON_BASE = 32768;
public static final int PLACE_PAGE_BUTTON_BASE = 65536;
private static final int PAGE_ADDRESS_COUNT = WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES;
public static final int MOVE_PAGE_BUTTON_BASE = PLACE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT;
private static final int PLAYER_START = COPY_RESULT + 1;
private static final int EXTRACT_TARGET_COUNT = PLAYER_START + 36 + 1;
public static final int EXTRACT_PAGE_BUTTON_BASE = MOVE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * PAGE_ADDRESS_COUNT;
private final Container desk;
private final WritingDeskBlockEntity blockEntity;
private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide;
private int syncedActiveCollection;
private int syncedInkRemaining;
private int firstVisibleTab;
private int selectedPage;
private int selectedCollection = -1;
private ResourceLocation selectedSymbol;
private List<SymbolDisplayMetadata> catalog = List.of();
private final ServerPlayer serverViewer;
public WritingDeskMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof WritingDeskBlockEntity found ? found : null);
catalog = WritingDeskCatalog.read(data);
}
public WritingDeskMenu(int id, Inventory inventory, WritingDeskBlockEntity blockEntity) {
super(ModMenus.WRITING_DESK.get(), id);
this.blockEntity = blockEntity;
this.desk = blockEntity == null ? new SimpleContainer(WritingDeskBlockEntity.SIZE) : blockEntity;
this.clientSide = inventory.player.level().isClientSide;
this.serverViewer = inventory.player instanceof ServerPlayer serverPlayer ? serverPlayer : null;
if (blockEntity != null && !clientSide) catalog = WritingDeskCatalog.available(blockEntity);
firstVisibleTab = blockEntity == null ? 0 : blockEntity.activeCollection() / VISIBLE_TABS * VISIBLE_TABS;
addDataSlot(new DataSlot() {
@Override public int get() { return firstVisibleTab; }
@Override public void set(int value) { firstVisibleTab = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedActiveCollection : WritingDeskMenu.this.blockEntity.activeCollection(); }
@Override public void set(int value) { syncedActiveCollection = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedInkRemaining : WritingDeskMenu.this.blockEntity.inkRemaining(); }
@Override public void set(int value) { syncedInkRemaining = value; }
});
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) {
addSlot(new DeskSlot(desk, i, 37, 34 + (i % VISIBLE_TABS) * 37, 1));
}
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.INK, 385, 28, 16));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.RETURN, 385, 80, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.PAPER, 241, 28, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.LEGACY_OUTPUT, -1000, -1000, 64));
addSlot(new CopySlot(result, 0, 241, 80));
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
addSlot(new Slot(inventory, column + row * 9 + 9, 241 + column * 18, 104 + row * 18));
}
}
for (int column = 0; column < 9; column++) {
addSlot(new Slot(inventory, column, 241 + column * 18, 162));
}
}
public int activeCollection() { return syncedActiveCollection; }
public int firstVisibleTab() { return firstVisibleTab; }
public int inkRemaining() { return syncedInkRemaining; }
public int pageCapacity(int collectionIndex) {
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS) return 0;
ItemStack collection = desk.getItem(collectionIndex);
return collection.is(ModItems.NOTEBOOK.get()) ? NotebookContents.MAX_PAGES
: collection.is(ModItems.PAGE.get()) ? 1 : 0;
}
public List<SymbolDisplayMetadata> catalog() { return catalog; }
public void setCatalog(List<SymbolDisplayMetadata> symbols) { catalog = List.copyOf(symbols); }
public static int movePageButtonId(int sourceCollection, int sourcePage, int targetCollection, int targetPage) {
int source = sourceCollection * NotebookContents.MAX_PAGES + sourcePage;
int target = targetCollection * NotebookContents.MAX_PAGES + targetPage;
return MOVE_PAGE_BUTTON_BASE + source * PAGE_ADDRESS_COUNT + target;
}
/** targetSlot is -1 for the cursor, otherwise a visible player or desk-tab slot. */
public static int extractPageButtonId(int sourceCollection, int sourcePage, int targetSlot) {
int source = sourceCollection * NotebookContents.MAX_PAGES + sourcePage;
return EXTRACT_PAGE_BUTTON_BASE + source * EXTRACT_TARGET_COUNT + targetSlot + 1;
}
@Override public boolean clickMenuButton(Player player, int id) {
if (blockEntity == null || !stillValid(player)) return false;
if (id == PREVIOUS_TABS || id == NEXT_TABS) {
firstVisibleTab = Math.clamp(firstVisibleTab + (id == NEXT_TABS ? VISIBLE_TABS : -VISIBLE_TABS),
0, WritingDeskBlockEntity.COLLECTIONS - VISIBLE_TABS);
return true;
} else if (id >= 0 && id < WritingDeskBlockEntity.COLLECTIONS) {
if (id < firstVisibleTab || id >= firstVisibleTab + VISIBLE_TABS) return false;
blockEntity.setActiveCollection(id);
selectedPage = 0;
selectedCollection = -1;
selectedSymbol = null;
} else if (id >= EXTRACT_PAGE_BUTTON_BASE
&& id < EXTRACT_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * EXTRACT_TARGET_COUNT) {
int offset = id - EXTRACT_PAGE_BUTTON_BASE;
int source = offset / EXTRACT_TARGET_COUNT;
int targetSlot = offset % EXTRACT_TARGET_COUNT - 1;
if (!extractPage(source / NotebookContents.MAX_PAGES, source % NotebookContents.MAX_PAGES,
targetSlot)) return false;
} else if (id >= MOVE_PAGE_BUTTON_BASE && id < MOVE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * PAGE_ADDRESS_COUNT) {
int offset = id - MOVE_PAGE_BUTTON_BASE;
int source = offset / PAGE_ADDRESS_COUNT;
int target = offset % PAGE_ADDRESS_COUNT;
if (!movePage(source / NotebookContents.MAX_PAGES, source % NotebookContents.MAX_PAGES,
target / NotebookContents.MAX_PAGES, target % NotebookContents.MAX_PAGES)) return false;
} else if (id >= PLACE_PAGE_BUTTON_BASE
&& id < PLACE_PAGE_BUTTON_BASE + WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES) {
int offset = id - PLACE_PAGE_BUTTON_BASE;
if (!placeCarriedPage(offset / NotebookContents.MAX_PAGES, offset % NotebookContents.MAX_PAGES)) return false;
} else if (id >= PAGE_BUTTON_BASE && id < PAGE_BUTTON_BASE + NotebookContents.MAX_PAGES) {
selectedPage = id - PAGE_BUTTON_BASE;
selectedCollection = -1;
selectedSymbol = null;
} else if (id >= ALL_PAGE_BUTTON_BASE && id < ALL_PAGE_BUTTON_BASE + WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES) {
int offset = id - ALL_PAGE_BUTTON_BASE;
int collection = offset / NotebookContents.MAX_PAGES;
int page = offset % NotebookContents.MAX_PAGES;
if (pageAt(collection, page).isEmpty()) return false;
selectedCollection = collection;
selectedPage = page;
selectedSymbol = null;
} else if (id >= CATALOG_BUTTON_BASE && id < CATALOG_BUTTON_BASE + catalog.size()) {
List<SymbolDisplayMetadata> available = WritingDeskCatalog.available(blockEntity);
if (!catalog.equals(available)) {
refreshCatalog(available);
return false;
}
selectedSymbol = available.get(id - CATALOG_BUTTON_BASE).id();
} else return false;
refreshOutput();
return true;
}
private boolean placeCarriedPage(int collectionIndex, int pageIndex) {
// Resolve the actual storage position on the server, never a filtered list index.
ItemStack carried = getCarried();
if (clientSide || !carried.is(ModItems.PAGE.get())) return false;
ItemStack collection = desk.getItem(collectionIndex);
if (collection.is(ModItems.NOTEBOOK.get())) {
var pages = new java.util.ArrayList<>(collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
while (pages.size() <= pageIndex) pages.add(ItemStack.EMPTY);
if (!pages.get(pageIndex).isEmpty()) return false;
pages.set(pageIndex, carried.copyWithCount(1));
ItemStack updated = collection.copy();
updated.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
desk.setItem(collectionIndex, updated);
} else return false;
carried.shrink(1);
setCarried(carried.isEmpty() ? ItemStack.EMPTY : carried);
selectedCollection = collectionIndex;
selectedPage = pageIndex;
selectedSymbol = null;
refreshCatalog();
broadcastChanges();
return true;
}
private boolean movePage(int sourceCollection, int sourcePage, int targetCollection, int targetPage) {
if (!getCarried().isEmpty() || sourceCollection == targetCollection && sourcePage == targetPage) return false;
ItemStack sourceBook = desk.getItem(sourceCollection);
ItemStack targetBook = desk.getItem(targetCollection);
boolean sourceNotebook = sourceBook.is(ModItems.NOTEBOOK.get());
boolean targetNotebook = targetBook.is(ModItems.NOTEBOOK.get());
if ((!sourceNotebook && (!sourceBook.is(ModItems.PAGE.get()) || sourcePage != 0))
|| (!targetNotebook && (!targetBook.is(ModItems.PAGE.get()) || targetPage != 0))) return false;
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
if (sourceNotebook && (sourcePage >= sourcePages.size() || sourcePages.get(sourcePage).isEmpty())) return false;
ItemStack moving = sourceNotebook ? sourcePages.get(sourcePage) : sourceBook;
if (moving.getCount() != 1) return false;
if (!targetNotebook && targetBook.getCount() != 1) return false;
var targetPages = targetNotebook ? sourceCollection == targetCollection ? sourcePages
: new java.util.ArrayList<>(targetBook.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
if (targetNotebook) while (targetPages.size() <= targetPage) targetPages.add(ItemStack.EMPTY);
ItemStack displaced = targetNotebook ? targetPages.get(targetPage) : targetBook;
if (targetNotebook) targetPages.set(targetPage, moving);
if (sourceNotebook) {
sourcePages.set(sourcePage, displaced);
ItemStack updatedSource = sourceBook.copy();
updatedSource.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(sourcePages));
desk.setItem(sourceCollection, updatedSource);
} else desk.setItem(sourceCollection, displaced.isEmpty() ? ItemStack.EMPTY : displaced.copyWithCount(1));
if (targetNotebook && sourceCollection != targetCollection) {
ItemStack updatedTarget = targetBook.copy();
updatedTarget.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(targetPages));
desk.setItem(targetCollection, updatedTarget);
} else if (!targetNotebook) desk.setItem(targetCollection, moving.copyWithCount(1));
selectedCollection = targetCollection;
selectedPage = targetPage;
selectedSymbol = null;
refreshCatalog();
broadcastChanges();
return true;
}
private boolean extractPage(int sourceCollection, int sourcePage, int targetSlot) {
if (!getCarried().isEmpty() || targetSlot < -1 || targetSlot >= slots.size()) return false;
ItemStack sourceBook = desk.getItem(sourceCollection);
boolean sourceNotebook = sourceBook.is(ModItems.NOTEBOOK.get());
if (!sourceNotebook && (!sourceBook.is(ModItems.PAGE.get()) || sourcePage != 0)) return false;
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
if (sourceNotebook && (sourcePage >= sourcePages.size() || sourcePages.get(sourcePage).isEmpty())) return false;
ItemStack moving = sourceNotebook ? sourcePages.get(sourcePage).copyWithCount(1) : sourceBook.copyWithCount(1);
if (!sourceNotebook && sourceBook.getCount() != 1) return false;
if (targetSlot == -1) {
setCarried(moving);
} else {
if (targetSlot >= WritingDeskBlockEntity.COLLECTIONS && targetSlot < PLAYER_START) return false;
Slot target = slots.get(targetSlot);
if (!target.isActive() || !target.mayPlace(moving)) return false;
ItemStack current = target.getItem();
if (targetSlot < WritingDeskBlockEntity.COLLECTIONS && current.is(ModItems.NOTEBOOK.get())) {
var targetPages = targetSlot == sourceCollection ? sourcePages
: new java.util.ArrayList<>(current.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
int position = -1;
for (int i = 0; i < NotebookContents.MAX_PAGES; i++) {
if (i >= targetPages.size() || targetPages.get(i).isEmpty()) {
position = i;
break;
}
}
if (position < 0) return false;
while (targetPages.size() <= position) targetPages.add(ItemStack.EMPTY);
targetPages.set(position, moving);
if (targetSlot != sourceCollection) {
ItemStack updatedTarget = current.copy();
updatedTarget.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(targetPages));
target.setByPlayer(updatedTarget);
}
} else if (current.isEmpty()) {
target.setByPlayer(moving);
} else if (targetSlot >= PLAYER_START && ItemStack.isSameItemSameComponents(current, moving)
&& current.getCount() < target.getMaxStackSize(moving)) {
target.setByPlayer(current.copyWithCount(current.getCount() + 1));
} else return false;
}
if (sourceNotebook) {
sourcePages.set(sourcePage, ItemStack.EMPTY);
ItemStack updatedSource = sourceBook.copy();
updatedSource.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(sourcePages));
desk.setItem(sourceCollection, updatedSource);
} else desk.setItem(sourceCollection, ItemStack.EMPTY);
selectedCollection = sourceCollection;
selectedPage = sourcePage;
selectedSymbol = null;
refreshCatalog();
broadcastChanges();
return true;
}
@Override public boolean stillValid(Player player) {
return blockEntity != null && blockEntity.stillValid(player);
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
if (index == COPY_RESULT) {
refreshOutput();
if (!canCopy()) return ItemStack.EMPTY;
ItemStack copy = selectedSource().copyWithCount(1);
ItemStack movingCopy = copy.copy();
if (!moveItemStackTo(movingCopy, PLAYER_START, slots.size(), true) || !movingCopy.isEmpty()) {
return ItemStack.EMPTY;
}
blockEntity.consumeCopyMaterials();
refreshOutput();
return copy;
}
Slot slot = slots.get(index);
if (!slot.isActive()) return ItemStack.EMPTY;
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack moving = slot.getItem();
ItemStack original = moving.copy();
if (index < COPY_RESULT) {
if (!moveItemStackTo(moving, PLAYER_START, slots.size(), true)) return ItemStack.EMPTY;
} else if (!moveIntoDesk(moving)) {
return ItemStack.EMPTY;
}
if (moving.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
else slot.setChanged();
return original;
}
private boolean moveIntoDesk(ItemStack moving) {
if (moving.is(ModItems.INK_VIAL.get())) return moveItemStackTo(moving,
WritingDeskBlockEntity.INK, WritingDeskBlockEntity.INK + 1, false);
if (moving.is(ModItems.NOTEBOOK.get())) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
if (moving.is(Items.PAPER))
return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER,
WritingDeskBlockEntity.PAPER + 1, false);
if (moving.is(ModItems.PAGE.get())) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
return false;
}
private ItemStack selectedSource() {
if (selectedSymbol != null) {
SymbolReference reference = new SymbolReference(selectedSymbol);
return SymbolCatalog.isKnown(reference)
&& blockEntity != null
&& WritingDeskCatalog.available(blockEntity).stream()
.anyMatch(entry -> entry.id().equals(selectedSymbol))
? PageItem.createSymbolPage(reference) : ItemStack.EMPTY;
}
int collectionIndex = selectedCollection >= 0 ? selectedCollection
: blockEntity == null ? syncedActiveCollection : blockEntity.activeCollection();
return pageAt(collectionIndex, selectedPage);
}
private ItemStack pageAt(int collectionIndex, int pageIndex) {
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS
|| pageIndex < 0 || pageIndex >= NotebookContents.MAX_PAGES) return ItemStack.EMPTY;
ItemStack collection = desk.getItem(collectionIndex);
if (collection.is(ModItems.PAGE.get())) return pageIndex == 0 ? collection : ItemStack.EMPTY;
if (collection.is(ModItems.NOTEBOOK.get())) {
List<ItemStack> pages = collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
return pageIndex < pages.size() ? pages.get(pageIndex) : ItemStack.EMPTY;
}
return ItemStack.EMPTY;
}
private boolean canCopy() {
ItemStack source = selectedSource();
return blockEntity != null && source.is(ModItems.PAGE.get())
&& PageItem.content(source).kind() != PageContent.Kind.BLANK
&& PageItem.isUsableWriting(source) && blockEntity.canConsumeCopyMaterials();
}
private void refreshOutput() {
if (clientSide) return;
if (blockEntity != null) blockEntity.setDisplayPage(selectedSource());
ItemStack preview = canCopy() ? selectedSource().copyWithCount(1) : ItemStack.EMPTY;
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
}
private void refreshCatalog() {
if (clientSide || blockEntity == null) return;
refreshCatalog(WritingDeskCatalog.available(blockEntity));
}
private void refreshCatalog(List<SymbolDisplayMetadata> current) {
if (clientSide || blockEntity == null) return;
if (!current.equals(catalog)) {
catalog = current;
if (serverViewer != null && serverViewer.containerMenu == this) {
PacketDistributor.sendToPlayer(serverViewer, new WritingDeskCatalogPayload(containerId, catalog));
}
}
}
@Override public void broadcastChanges() {
refreshCatalog();
refreshOutput();
super.broadcastChanges();
}
@Override public void clicked(int slotId, int button, net.minecraft.world.inventory.ClickType clickType,
Player player) {
if (!stillValid(player)) {
player.closeContainer();
return;
}
refreshCatalog();
refreshOutput();
if (slotId >= 0 && slotId < slots.size() && !slots.get(slotId).isActive()) return;
super.clicked(slotId, button, clickType, player);
}
private final class CopySlot extends Slot {
private CopySlot(Container container, int index, int x, int y) { super(container, index, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) {
return clientSide ? hasItem() : WritingDeskMenu.this.stillValid(player) && canCopy();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && blockEntity.consumeCopyMaterials()) refreshOutput();
super.onTake(player, stack);
}
}
private final class DeskSlot extends Slot {
private final int limit;
private DeskSlot(Container container, int index, int x, int y, int limit) {
super(container, index, x, y);
this.limit = limit;
}
@Override public boolean isActive() {
int slot = getContainerSlot();
return slot >= WritingDeskBlockEntity.COLLECTIONS
|| slot >= firstVisibleTab && slot < firstVisibleTab + VISIBLE_TABS;
}
@Override public boolean mayPickup(Player player) { return isActive(); }
@Override public boolean mayPlace(ItemStack stack) { return isActive() && container.canPlaceItem(getContainerSlot(), stack); }
@Override public int getMaxStackSize() { return limit; }
}
}
@@ -0,0 +1,59 @@
package net.dnorberg.rivencraft.mixin.client;
import net.dnorberg.rivencraft.client.preview.ImmersivePortalsPreviewAudioGuard;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.Holder;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Mutes only the remote client world displayed by a Rivencraft book preview. */
@Mixin(value = ClientLevel.class, priority = 2000)
public abstract class ClientLevelSoundMixin {
@Inject(
method = "playSound(DDDLnet/minecraft/sounds/SoundEvent;Lnet/minecraft/sounds/SoundSource;FFZJ)V",
at = @At("HEAD"),
cancellable = true
)
private void rivencraft$mutePreviewPositionSound(
double x, double y, double z, SoundEvent sound, SoundSource source,
float volume, float pitch, boolean distanceDelay, long seed, CallbackInfo callback
) {
rivencraft$cancelPreviewSound(callback);
}
@Inject(
method = "playLocalSound(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/sounds/SoundEvent;Lnet/minecraft/sounds/SoundSource;FF)V",
at = @At("HEAD"),
cancellable = true
)
private void rivencraft$mutePreviewLocalEntitySound(
Entity entity, SoundEvent sound, SoundSource source, float volume, float pitch,
CallbackInfo callback
) {
rivencraft$cancelPreviewSound(callback);
}
@Inject(
method = "playSeededSound(Lnet/minecraft/world/entity/player/Player;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/Holder;Lnet/minecraft/sounds/SoundSource;FFJ)V",
at = @At("HEAD"),
cancellable = true
)
private void rivencraft$mutePreviewSeededEntitySound(
Player player, Entity entity, Holder<SoundEvent> sound, SoundSource source,
float volume, float pitch, long seed, CallbackInfo callback
) {
rivencraft$cancelPreviewSound(callback);
}
private void rivencraft$cancelPreviewSound(CallbackInfo callback) {
if (ImmersivePortalsPreviewAudioGuard.shouldMute((ClientLevel)(Object)this)) {
callback.cancel();
}
}
}
@@ -0,0 +1,18 @@
package net.dnorberg.rivencraft.mixin.client;
import net.dnorberg.rivencraft.client.preview.ImmersivePortalsPreviewAudioGuard;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Restores the player camera after Immersive Portals finishes GUI-world rendering. */
@Pseudo
@Mixin(targets = "qouteall.imm_ptl.core.render.GuiPortalRendering", remap = false)
public abstract class ImmersivePortalsGuiRenderingMixin {
@Inject(method = "_onGameRenderEnd", at = @At("RETURN"), remap = false)
private static void rivencraft$restorePlayerCamera(CallbackInfo callback) {
ImmersivePortalsPreviewAudioGuard.restorePlayerCamera();
}
}
@@ -9,11 +9,18 @@ import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler;
import net.dnorberg.rivencraft.client.ClientBookInteractionHandler;
import net.dnorberg.rivencraft.client.ClientAgeCelestialAngles;
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.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
@@ -48,7 +55,7 @@ public final class ModNetworking {
}
public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("9");
PayloadRegistrar registrar = event.registrar("13");
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
ModNetworking::requestDescriptiveBookScreen);
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
@@ -68,6 +75,18 @@ public final class ModNetworking {
ClientLinkPreviewHandler::handle);
registrar.playToClient(AgeCelestialAnglePayload.TYPE, AgeCelestialAnglePayload.STREAM_CODEC,
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) {
@@ -89,10 +108,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) {
@@ -110,6 +134,14 @@ public final class ModNetworking {
return;
}
var grammar = WritingGrammar.parse(access.stack()
.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY));
if (!grammar.canCreate()) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.invalid_writing",
grammar.firstError().orElseThrow().message()), true);
return;
}
WRITING_BOOKS.add(access.stack());
try {
AgeReference reference = new AgeReference(UUID.randomUUID(), title);
@@ -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; }
}
@@ -0,0 +1,26 @@
package net.dnorberg.rivencraft.network;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.ResourceLocation;
/** Current server-approved symbols from this open Writing Desk menu. */
public record WritingDeskCatalogPayload(int containerId, List<SymbolDisplayMetadata> symbols)
implements CustomPacketPayload {
public static final Type<WritingDeskCatalogPayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "writing_desk_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, WritingDeskCatalogPayload> STREAM_CODEC =
StreamCodec.of(
(buffer, payload) -> {
buffer.writeVarInt(payload.containerId());
WritingDeskCatalog.write(buffer, payload.symbols());
},
buffer -> new WritingDeskCatalogPayload(buffer.readVarInt(), WritingDeskCatalog.read(buffer)));
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -2,6 +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;
@@ -16,6 +19,15 @@ public final class ModBlockEntities {
BLOCK_ENTITIES.register("book_stand", () -> BlockEntityType.Builder
.of(BookStandBlockEntity::new, ModBlocks.BOOK_STAND.get())
.build(null));
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,6 +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;
@@ -16,6 +19,18 @@ public final class ModBlocks {
BookStandBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN)
);
public static final DeferredBlock<WritingDeskBlock> WRITING_DESK = BLOCKS.registerBlock(
"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() {
}
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.CreativeModeTab;
@@ -19,6 +20,25 @@ public final class ModCreativeTabs {
.displayItems((parameters, output) -> {
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
output.accept(ModItems.LINKING_BOOK.get());
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());
public static final DeferredHolder<CreativeModeTab, CreativeModeTab> PAGES =
CREATIVE_TABS.register("rivencraft_pages", () -> CreativeModeTab.builder()
.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());
@@ -3,6 +3,11 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
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;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceLocation;
@@ -39,6 +44,36 @@ public final class ModDataComponents {
.networkSynchronized(ByteBufCodecs.STRING_UTF8)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<PageContent>> PAGE_CONTENT =
DATA_COMPONENTS.register("page_content", () -> DataComponentType.<PageContent>builder()
.persistent(PageContent.CODEC)
.networkSynchronized(PageContent.STREAM_CODEC)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<SymbolDisplayMetadata>> PAGE_SYMBOL_DISPLAY =
DATA_COMPONENTS.register("page_symbol_display", () -> DataComponentType.<SymbolDisplayMetadata>builder()
.persistent(SymbolDisplayMetadata.CODEC)
.networkSynchronized(SymbolDisplayMetadata.STREAM_CODEC)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<NotebookContents>> NOTEBOOK_CONTENTS =
DATA_COMPONENTS.register("notebook_contents", () -> DataComponentType.<NotebookContents>builder()
.persistent(NotebookContents.CODEC)
.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))
.networkSynchronized(ByteBufCodecs.VAR_INT)
.cacheEncoding()
.build());
private ModDataComponents() {
}
@@ -3,7 +3,11 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.NotebookItem;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.BlockItem;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredItem;
import net.neoforged.neoforge.registries.DeferredRegister;
@@ -21,6 +25,24 @@ public final class ModItems {
LinkingBookItem::new,
new Item.Properties().stacksTo(1)
);
public static final DeferredItem<PageItem> PAGE = ITEMS.registerItem(
"page",
PageItem::new,
new Item.Properties().stacksTo(64)
);
public static final DeferredItem<NotebookItem> NOTEBOOK = ITEMS.registerItem(
"notebook",
NotebookItem::new,
new Item.Properties().stacksTo(1)
);
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
"ink_vial",
InkVialItem::new,
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() {
}
@@ -0,0 +1,34 @@
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;
import net.neoforged.neoforge.common.extensions.IMenuTypeExtension;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
public final class ModMenus {
private static final DeferredRegister<MenuType<?>> MENUS =
DeferredRegister.create(Registries.MENU, Rivencraft.MOD_ID);
public static final DeferredHolder<MenuType<?>, MenuType<NotebookMenu>> NOTEBOOK =
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() {
}
public static void register(IEventBus modEventBus) {
MENUS.register(modEventBus);
}
}
@@ -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();
}
}
@@ -0,0 +1,88 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.world.item.ItemStack;
/** A bounded, position-preserving collection of Page stacks stored on a Notebook item. */
public record NotebookContents(List<ItemStack> pages) {
public static final int MAX_PAGES = 27;
public static final NotebookContents EMPTY = new NotebookContents(List.of());
public static final Codec<NotebookContents> CODEC = ItemStack.OPTIONAL_CODEC.listOf()
.validate(NotebookContents::validate)
.xmap(NotebookContents::new, NotebookContents::pages);
public static final StreamCodec<RegistryFriendlyByteBuf, NotebookContents> STREAM_CODEC = StreamCodec.of(
NotebookContents::encode, NotebookContents::decode);
public NotebookContents {
if (pages.size() > MAX_PAGES) {
throw new IllegalArgumentException("Notebook exceeds " + MAX_PAGES + " page positions");
}
List<ItemStack> copies = new ArrayList<>(pages.size());
for (ItemStack page : pages) {
validatePage(page).getOrThrow();
copies.add(page.copy());
}
pages = List.copyOf(copies);
}
public int pageCount() {
return (int)pages.stream().filter(page -> !page.isEmpty()).count();
}
public int unresolvedCount() {
return (int)pages.stream()
.filter(page -> !page.isEmpty()
&& PageContent.Kind.UNRESOLVED_SYMBOL == PageItem.content(page).kind())
.count();
}
private static DataResult<List<ItemStack>> validate(List<ItemStack> pages) {
if (pages.size() > MAX_PAGES) {
return DataResult.error(() -> "Notebook exceeds " + MAX_PAGES + " page positions");
}
for (ItemStack page : pages) {
DataResult<ItemStack> result = validatePage(page);
if (result.error().isPresent()) {
return DataResult.error(() -> result.error().orElseThrow().message());
}
}
return DataResult.success(pages);
}
private static DataResult<ItemStack> validatePage(ItemStack page) {
if (page.isEmpty()) {
return DataResult.success(page);
}
if (!page.is(ModItems.PAGE.get())) {
return DataResult.error(() -> "Notebook contains a non-Page item");
}
return page.getCount() == 1
? DataResult.success(page)
: DataResult.error(() -> "Notebook Page stacks must contain exactly one item");
}
private static void encode(RegistryFriendlyByteBuf buffer, NotebookContents contents) {
ByteBufCodecs.VAR_INT.encode(buffer, contents.pages.size());
contents.pages.forEach(page -> ItemStack.OPTIONAL_STREAM_CODEC.encode(buffer, page));
}
private static NotebookContents decode(RegistryFriendlyByteBuf buffer) {
int size = ByteBufCodecs.VAR_INT.decode(buffer);
if (size < 0 || size > MAX_PAGES) {
throw new IllegalArgumentException("Invalid Notebook page count " + size);
}
List<ItemStack> pages = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
pages.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(buffer));
}
return new NotebookContents(pages);
}
}
@@ -0,0 +1,68 @@
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;
import java.util.Objects;
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(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());
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"));
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);
}
List<WritingEntry> entries = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
entries.add(WritingEntry.STREAM_CODEC.decode(buffer));
}
return new OrderedWriting(schemaVersion, entries);
}
}
@@ -0,0 +1,95 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** Persistent contents of a Page item. Unknown symbols remain explicit and recoverable. */
public record PageContent(Kind kind, Optional<SymbolReference> symbol) {
public static final PageContent BLANK = new PageContent(Kind.BLANK, Optional.empty());
public static final PageContent LINK_PANEL = new PageContent(Kind.LINK_PANEL, Optional.empty());
private static final Codec<Kind> KIND_CODEC = Codec.STRING.comapFlatMap(
value -> {
try {
return DataResult.success(Kind.valueOf(value.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return DataResult.error(() -> "Unknown page content kind: " + value);
}
},
value -> value.name().toLowerCase(Locale.ROOT)
);
private static final Codec<PageContent> RECORD_CODEC = RecordCodecBuilder.create(instance -> instance.group(
KIND_CODEC.fieldOf("kind").forGetter(PageContent::kind),
SymbolReference.CODEC.optionalFieldOf("symbol").forGetter(PageContent::symbol)
).apply(instance, PageContent::new));
public static final Codec<PageContent> CODEC = RECORD_CODEC.flatXmap(PageContent::validate, PageContent::validate);
public static final StreamCodec<ByteBuf, PageContent> STREAM_CODEC = StreamCodec.of(
PageContent::encode, PageContent::decode);
public PageContent {
Objects.requireNonNull(kind, "kind");
symbol = Objects.requireNonNull(symbol, "symbol");
boolean needsSymbol = kind == Kind.SYMBOL || kind == Kind.UNRESOLVED_SYMBOL;
if (needsSymbol != symbol.isPresent()) {
throw new IllegalArgumentException("Page contents do not match kind " + kind);
}
}
public static PageContent symbol(SymbolReference symbol) {
return new PageContent(Kind.SYMBOL, Optional.of(symbol));
}
public static PageContent unresolved(SymbolReference symbol) {
return new PageContent(Kind.UNRESOLVED_SYMBOL, Optional.of(symbol));
}
/** Re-resolves persisted data against the authoritative current server catalog. */
public PageContent resolve() {
if (kind != Kind.SYMBOL && kind != Kind.UNRESOLVED_SYMBOL) {
return this;
}
SymbolReference reference = symbol.orElseThrow();
return SymbolCatalog.isKnown(reference) ? symbol(reference) : unresolved(reference);
}
public boolean isResolvedSymbol() {
return kind == Kind.SYMBOL;
}
private static DataResult<PageContent> validate(PageContent content) {
boolean needsSymbol = content.kind == Kind.SYMBOL || content.kind == Kind.UNRESOLVED_SYMBOL;
return needsSymbol == content.symbol.isPresent()
? DataResult.success(content)
: DataResult.error(() -> "Page contents do not match kind " + content.kind);
}
private static void encode(ByteBuf buffer, PageContent content) {
ByteBufCodecs.VAR_INT.encode(buffer, content.kind.ordinal());
content.symbol.ifPresent(reference -> SymbolReference.STREAM_CODEC.encode(buffer, reference));
}
private static PageContent decode(ByteBuf buffer) {
int ordinal = ByteBufCodecs.VAR_INT.decode(buffer);
if (ordinal < 0 || ordinal >= Kind.values().length) {
throw new IllegalArgumentException("Unknown page content kind ordinal " + ordinal);
}
Kind kind = Kind.values()[ordinal];
Optional<SymbolReference> symbol = kind == Kind.SYMBOL || kind == Kind.UNRESOLVED_SYMBOL
? Optional.of(SymbolReference.STREAM_CODEC.decode(buffer))
: Optional.empty();
return new PageContent(kind, symbol);
}
public enum Kind {
BLANK,
SYMBOL,
UNRESOLVED_SYMBOL,
LINK_PANEL
}
}
@@ -0,0 +1,69 @@
package net.dnorberg.rivencraft.writing;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener;
import net.minecraft.util.profiling.ProfilerFiller;
import net.neoforged.neoforge.event.AddReloadListenerEvent;
/** Authoritative server catalog loaded from data/&lt;namespace&gt;/rivencraft/symbols. */
public final class SymbolCatalog {
private static final Gson GSON = new GsonBuilder().create();
private static final String DIRECTORY = "rivencraft/symbols";
private static volatile Map<ResourceLocation, SymbolDefinition> symbols = Map.of();
private SymbolCatalog() {
}
public static void register(AddReloadListenerEvent event) {
event.addListener(new Loader());
}
public static Optional<SymbolDefinition> find(SymbolReference reference) {
return Optional.ofNullable(symbols.get(reference.id()));
}
public static boolean isKnown(SymbolReference reference) {
return symbols.containsKey(reference.id());
}
public static SymbolDisplayMetadata display(SymbolReference reference) {
SymbolDefinition definition = symbols.get(reference.id());
return definition == null
? SymbolDisplayMetadata.unresolved(reference)
: definition.display(reference.id());
}
public static Map<ResourceLocation, SymbolDefinition> symbols() {
return symbols;
}
private static final class Loader extends SimpleJsonResourceReloadListener {
private Loader() {
super(GSON, DIRECTORY);
}
@Override
protected void apply(Map<ResourceLocation, JsonElement> resources,
ResourceManager resourceManager,
ProfilerFiller profiler) {
Map<ResourceLocation, SymbolDefinition> loaded = new HashMap<>();
resources.forEach((id, json) -> {
try {
loaded.put(id, SymbolDefinition.parse(json));
} catch (IllegalArgumentException exception) {
Rivencraft.LOGGER.error("Ignoring invalid writing symbol {}: {}", id, exception.getMessage());
}
});
symbols = Map.copyOf(loaded);
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
}
}
}
@@ -0,0 +1,127 @@
package net.dnorberg.rivencraft.writing;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import net.minecraft.resources.ResourceLocation;
/** Server-only symbol definition. Only display metadata crosses to clients. */
public record SymbolDefinition(
int schemaVersion,
String nameTranslationKey,
String descriptionTranslationKey,
ResourceLocation category,
Optional<SymbolSemantics> semantics,
String poem
) {
public static final int CURRENT_SCHEMA_VERSION = 2;
private static final Pattern TRANSLATION_KEY = Pattern.compile("[a-z0-9_.-]+");
private static final Set<String> V2_FIELDS = Set.of(
"schema_version", "name", "description", "category", "semantics", "poem");
public SymbolDefinition {
Objects.requireNonNull(nameTranslationKey, "nameTranslationKey");
Objects.requireNonNull(descriptionTranslationKey, "descriptionTranslationKey");
Objects.requireNonNull(category, "category");
semantics = Objects.requireNonNull(semantics, "semantics");
poem = Objects.requireNonNull(poem, "poem");
if (schemaVersion != 1 && schemaVersion != CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported schema_version " + schemaVersion);
}
if (schemaVersion == 1 && semantics.isPresent()) {
throw new IllegalArgumentException("Schema v1 symbols cannot have semantics");
}
if (schemaVersion == CURRENT_SCHEMA_VERSION && semantics.isEmpty()) {
throw new IllegalArgumentException("Schema v2 symbols require semantics");
}
validateTranslationKey(nameTranslationKey, "name");
validateTranslationKey(descriptionTranslationKey, "description");
if (!poem.isEmpty() && (poem.length() > 96
|| !poem.matches("[A-Za-z]+(?: [A-Za-z]+){3}"))) {
throw new IllegalArgumentException("'poem' must contain exactly four alphabetic words");
}
}
/** v1 remains display-only; v2 has an exact field set and typed semantics. */
public static SymbolDefinition parse(JsonElement json) {
if (json == null || !json.isJsonObject()) {
throw new IllegalArgumentException("Symbol definition must be a JSON object");
}
JsonObject object = json.getAsJsonObject();
int version = requiredInteger(object, "schema_version");
if (version != 1 && version != CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported schema_version " + version);
}
if (version == CURRENT_SCHEMA_VERSION) {
for (String field : object.keySet()) {
if (!V2_FIELDS.contains(field)) {
throw new IllegalArgumentException("Unknown symbol field '" + field + "'");
}
}
} else if (object.has("semantics")) {
throw new IllegalArgumentException("Schema v1 cannot declare semantics; use schema_version 2");
}
String name = requiredString(object, "name");
String description = requiredString(object, "description");
ResourceLocation category = requiredResourceLocation(object, "category",
version == CURRENT_SCHEMA_VERSION);
Optional<SymbolSemantics> semantics = version == CURRENT_SCHEMA_VERSION
? Optional.of(SymbolSemantics.parse(object.get("semantics")))
: Optional.empty();
String poem = object.has("poem") ? requiredString(object, "poem") : "";
return new SymbolDefinition(version, name, description, category, semantics, poem);
}
public SymbolDisplayMetadata display(ResourceLocation id) {
return new SymbolDisplayMetadata(id, nameTranslationKey, descriptionTranslationKey, category, true, poem);
}
static String requiredString(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null || !element.isJsonPrimitive()
|| !element.getAsJsonPrimitive().isString()) {
throw new IllegalArgumentException("'" + field + "' must be a string");
}
return element.getAsString();
}
static ResourceLocation requiredResourceLocation(JsonObject object, String field) {
return requiredResourceLocation(object, field, true);
}
private static ResourceLocation requiredResourceLocation(JsonObject object, String field, boolean canonical) {
String value = requiredString(object, field);
ResourceLocation id = ResourceLocation.tryParse(value);
if (id == null || (canonical && !id.toString().equals(value))) {
throw new IllegalArgumentException("'" + field + "' must be "
+ (canonical ? "a canonical namespaced ID" : "a resource location") + ": " + value);
}
return id;
}
private static int requiredInteger(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null || !element.isJsonPrimitive()) {
throw new IllegalArgumentException("'" + field + "' must be an integer");
}
JsonPrimitive primitive = element.getAsJsonPrimitive();
if (!primitive.isNumber() || !primitive.getAsString().matches("-?\\d+")) {
throw new IllegalArgumentException("'" + field + "' must be an integer");
}
try {
return Integer.parseInt(primitive.getAsString());
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("'" + field + "' is outside the integer range", exception);
}
}
private static void validateTranslationKey(String value, String field) {
if (!TRANSLATION_KEY.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid '" + field + "' translation key: " + value);
}
}
}
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.ResourceLocation;
/** Client-safe symbol presentation selected by the authoritative server catalog. */
public record SymbolDisplayMetadata(
ResourceLocation id,
String nameTranslationKey,
String descriptionTranslationKey,
ResourceLocation category,
boolean resolved,
String poem
) {
public static final Codec<SymbolDisplayMetadata> CODEC = RecordCodecBuilder.create(instance -> instance.group(
ResourceLocation.CODEC.fieldOf("id").forGetter(SymbolDisplayMetadata::id),
Codec.STRING.fieldOf("name").forGetter(SymbolDisplayMetadata::nameTranslationKey),
Codec.STRING.fieldOf("description").forGetter(SymbolDisplayMetadata::descriptionTranslationKey),
ResourceLocation.CODEC.fieldOf("category").forGetter(SymbolDisplayMetadata::category),
Codec.BOOL.fieldOf("resolved").forGetter(SymbolDisplayMetadata::resolved),
Codec.STRING.optionalFieldOf("poem", "").forGetter(SymbolDisplayMetadata::poem)
).apply(instance, SymbolDisplayMetadata::new));
public static final StreamCodec<ByteBuf, SymbolDisplayMetadata> STREAM_CODEC = StreamCodec.composite(
ResourceLocation.STREAM_CODEC, SymbolDisplayMetadata::id,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::nameTranslationKey,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::descriptionTranslationKey,
ResourceLocation.STREAM_CODEC, SymbolDisplayMetadata::category,
ByteBufCodecs.BOOL, SymbolDisplayMetadata::resolved,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::poem,
SymbolDisplayMetadata::new
);
public static SymbolDisplayMetadata unresolved(SymbolReference reference) {
return new SymbolDisplayMetadata(
reference.id(),
"symbol.rivencraft.unresolved",
"symbol.rivencraft.unresolved.description",
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "unresolved"),
false,
""
);
}
}
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.writing;
/**
* Number components from Mystcraft Legacy's ModWords#constructNumber.
* Legacy registers 0–25; larger Rivencraft tab numbers use positional base 25.
*/
public final class SymbolNumerals {
private SymbolNumerals() {}
public static int[] digitsFor(int number) {
if (number < 0) throw new IllegalArgumentException("Numeral must be non-negative");
if (number < 25) return new int[] {number};
int count = 0;
for (int remaining = number; remaining > 0; remaining /= 25) count++;
int[] digits = new int[count];
for (int remaining = number, index = count - 1; index >= 0; index--, remaining /= 25)
digits[index] = remaining % 25;
return digits;
}
public static int[] componentsForDigit(int digit) {
if (digit < 0 || digit >= 25) throw new IllegalArgumentException("Numeral digit must be in [0, 24]");
if (digit == 0) return new int[] {1};
int fifths = digit / 5;
int remainder = digit % 5;
if (fifths == 0) return new int[] {55 + remainder};
if (remainder == 0) return new int[] {59 + fifths};
return new int[] {59 + fifths, 55 + remainder};
}
}
@@ -0,0 +1,21 @@
package net.dnorberg.rivencraft.writing;
import net.minecraft.resources.ResourceLocation;
/** Legacy poems for existing starter pages, including stacks saved before poem metadata existed. */
public final class SymbolPoems {
private SymbolPoems() {}
public static String builtIn(ResourceLocation id) {
if (!id.getNamespace().equals("rivencraft")) return "";
return switch (id.getPath()) {
case "terrain/standard" -> "Terrain Form Tradition Flow";
case "biome/native" -> "Constraint Nature Tradition Sustain";
case "lighting/normal" -> "Ethereal Dynamic Cycle Balance";
case "weather/normal" -> "Sustain Dynamic Tradition Balance";
case "celestial/normal_time" -> "Transform Time System Balance";
case "feature/caves" -> "Terrain Transform Void Flow";
default -> "";
};
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import io.netty.buffer.ByteBuf;
import java.util.Objects;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.ResourceLocation;
/** Stable namespaced identity for one writing symbol. */
public record SymbolReference(ResourceLocation id) {
public static final Codec<SymbolReference> CODEC = ResourceLocation.CODEC.xmap(
SymbolReference::new, SymbolReference::id);
public static final StreamCodec<ByteBuf, SymbolReference> STREAM_CODEC = ResourceLocation.STREAM_CODEC.map(
SymbolReference::new, SymbolReference::id);
public SymbolReference {
Objects.requireNonNull(id, "id");
}
/** Remains useful when the defining data pack has been removed. */
public String unresolvedName() {
return id.toString();
}
}
@@ -0,0 +1,120 @@
package net.dnorberg.rivencraft.writing;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import java.util.Arrays;
import java.util.Objects;
import java.util.Set;
import net.minecraft.resources.ResourceLocation;
/** Bounded grammar role and value, retained by the server catalog only. */
public record SymbolSemantics(Role role, Value value) {
public SymbolSemantics {
Objects.requireNonNull(role, "role");
Objects.requireNonNull(value, "value");
if (!role.accepts(value)) {
throw new IllegalArgumentException("Invalid semantics.value for role '" + role.id
+ "': " + value + "; expected " + role.expected());
}
}
public static SymbolSemantics parse(JsonElement json) {
if (json == null || !json.isJsonObject()) {
throw new IllegalArgumentException("'semantics' must be an object");
}
JsonObject object = json.getAsJsonObject();
for (String field : object.keySet()) {
if (!field.equals("role") && !field.equals("value")) {
throw new IllegalArgumentException("Unknown semantics field '" + field + "'");
}
}
String roleId = SymbolDefinition.requiredString(object, "role");
Role role = Arrays.stream(Role.values()).filter(candidate -> candidate.id.equals(roleId))
.findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown semantic role '" + roleId + "'"));
JsonElement raw = object.get("value");
if (raw == null || !raw.isJsonPrimitive()) {
throw new IllegalArgumentException("'semantics.value' is required");
}
JsonPrimitive primitive = raw.getAsJsonPrimitive();
Value value;
if (role.kind == Kind.RESOURCE) {
value = new ResourceValue(SymbolDefinition.requiredResourceLocation(object, "value"));
} else if (role.kind == Kind.NUMBER) {
if (!primitive.isNumber()) {
throw new IllegalArgumentException("'semantics.value' for " + role.id + " must be a number");
}
value = new NumberValue(primitive.getAsDouble());
} else {
value = new ChoiceValue(SymbolDefinition.requiredString(object, "value"));
}
return new SymbolSemantics(role, value);
}
public sealed interface Value permits ResourceValue, ChoiceValue, NumberValue {}
public record ResourceValue(ResourceLocation id) implements Value {
public ResourceValue { Objects.requireNonNull(id, "id"); }
}
public record ChoiceValue(String id) implements Value {
public ChoiceValue { Objects.requireNonNull(id, "id"); }
}
public record NumberValue(double amount) implements Value {
public NumberValue {
if (!Double.isFinite(amount)) {
throw new IllegalArgumentException("Semantic numeric value must be finite");
}
}
}
private enum Kind { RESOURCE, CHOICE, NUMBER }
public enum Role {
TERRAIN("terrain", Kind.RESOURCE),
BIOME_CONTROLLER("biome_controller", Kind.CHOICE,
"native", "single", "tiled", "grid", "tiny", "small", "medium", "large", "huge"),
BIOME("biome", Kind.RESOURCE),
LIGHTING("lighting", Kind.CHOICE, "normal", "dark", "bright"),
WEATHER("weather", Kind.CHOICE,
"normal", "fast", "slow", "off", "always", "cloudy", "rain", "snow", "storm"),
CELESTIAL("celestial", Kind.CHOICE, "normal_time"),
PERIOD_MODIFIER("period_modifier", Kind.NUMBER),
ANGLE_MODIFIER("angle_modifier", Kind.NUMBER),
PHASE_MODIFIER("phase_modifier", Kind.NUMBER),
FEATURE("feature", Kind.RESOURCE);
private final String id;
private final Kind kind;
private final Set<String> choices;
Role(String id, Kind kind, String... choices) {
this.id = id;
this.kind = kind;
this.choices = Set.of(choices);
}
public String id() { return id; }
private boolean accepts(Value value) {
if (kind == Kind.RESOURCE) return value instanceof ResourceValue;
if (kind == Kind.CHOICE) return value instanceof ChoiceValue choice && choices.contains(choice.id());
if (!(value instanceof NumberValue number)) return false;
return switch (this) {
case PERIOD_MODIFIER -> number.amount() >= 0.0 && number.amount() <= 2.0;
case ANGLE_MODIFIER, PHASE_MODIFIER -> number.amount() >= 0.0 && number.amount() < 360.0;
default -> false;
};
}
private String expected() {
return switch (kind) {
case RESOURCE -> "a canonical namespaced ID";
case CHOICE -> "one of " + choices.stream().sorted().toList();
case NUMBER -> this == PERIOD_MODIFIER ? "a finite number in [0, 2]"
: "a finite number in [0, 360) degrees";
};
}
}
}
@@ -0,0 +1,63 @@
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 net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
/** A bounded, server-derived view of symbols physically present in the desk. */
public final class WritingDeskCatalog {
private static final int MAX_ENTRIES = WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES;
private WritingDeskCatalog() {}
public static List<SymbolDisplayMetadata> available(WritingDeskBlockEntity desk) {
Map<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);
if (collection.is(ModItems.NOTEBOOK.get())) {
List<ItemStack> pages = collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
pages.forEach(page -> add(unique, page));
}
}
return unique.values().stream()
.sorted(Comparator.comparing((SymbolDisplayMetadata symbol) -> symbol.category().toString())
.thenComparing(symbol -> symbol.id().toString()))
.toList();
}
private static void add(Map<ResourceLocation, SymbolDisplayMetadata> unique, ItemStack page) {
if (!page.is(ModItems.PAGE.get())) return;
PageContent content = PageItem.content(page).resolve();
if (content.kind() != PageContent.Kind.SYMBOL) return;
SymbolReference reference = content.symbol().orElseThrow();
SymbolCatalog.find(reference).ifPresent(definition ->
unique.put(reference.id(), definition.display(reference.id())));
}
public static void write(RegistryFriendlyByteBuf buffer, List<SymbolDisplayMetadata> entries) {
if (entries.size() > MAX_ENTRIES) throw new IllegalArgumentException("Desk symbol catalog exceeds limit");
buffer.writeVarInt(entries.size());
entries.forEach(entry -> SymbolDisplayMetadata.STREAM_CODEC.encode(buffer, entry));
}
public static List<SymbolDisplayMetadata> read(RegistryFriendlyByteBuf buffer) {
int count = buffer.readVarInt();
if (count < 0 || count > MAX_ENTRIES) throw new IllegalArgumentException("Invalid desk symbol count " + count);
List<SymbolDisplayMetadata> entries = new ArrayList<>(count);
for (int index = 0; index < count; index++) {
entries.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer));
}
return List.copyOf(entries);
}
}
@@ -0,0 +1,82 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** One ordered unit of book writing. Interpretation belongs to Milestone 5. */
public record WritingEntry(Kind kind, Optional<SymbolReference> symbol) {
private static final Codec<Kind> KIND_CODEC = Codec.STRING.comapFlatMap(
value -> {
try {
return DataResult.success(Kind.valueOf(value.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return DataResult.error(() -> "Unknown writing entry kind: " + value);
}
},
value -> value.name().toLowerCase(Locale.ROOT)
);
private static final Codec<WritingEntry> RECORD_CODEC = RecordCodecBuilder.create(instance -> instance.group(
KIND_CODEC.fieldOf("kind").forGetter(WritingEntry::kind),
SymbolReference.CODEC.optionalFieldOf("symbol").forGetter(WritingEntry::symbol)
).apply(instance, WritingEntry::new));
public static final Codec<WritingEntry> CODEC = RECORD_CODEC.flatXmap(
WritingEntry::validate, WritingEntry::validate);
public static final StreamCodec<ByteBuf, WritingEntry> STREAM_CODEC = StreamCodec.of(
WritingEntry::encode, WritingEntry::decode);
public WritingEntry {
Objects.requireNonNull(kind, "kind");
symbol = Objects.requireNonNull(symbol, "symbol");
if (kind == Kind.SYMBOL && symbol.isEmpty()) {
throw new IllegalArgumentException("Symbol writing entries require a symbol reference");
}
if (kind == Kind.LINK_PANEL && symbol.isPresent()) {
throw new IllegalArgumentException("Link-panel writing entries cannot contain a symbol reference");
}
}
public static WritingEntry symbol(SymbolReference symbol) {
return new WritingEntry(Kind.SYMBOL, Optional.of(symbol));
}
public static WritingEntry linkPanel() {
return new WritingEntry(Kind.LINK_PANEL, Optional.empty());
}
private static DataResult<WritingEntry> validate(WritingEntry entry) {
return entry.kind == Kind.SYMBOL && entry.symbol.isEmpty()
|| entry.kind == Kind.LINK_PANEL && entry.symbol.isPresent()
? DataResult.error(() -> "Writing entry contents do not match kind " + entry.kind)
: DataResult.success(entry);
}
private static void encode(ByteBuf buffer, WritingEntry entry) {
ByteBufCodecs.VAR_INT.encode(buffer, entry.kind.ordinal());
if (entry.kind == Kind.SYMBOL) {
SymbolReference.STREAM_CODEC.encode(buffer, entry.symbol.orElseThrow());
}
}
private static WritingEntry decode(ByteBuf buffer) {
int ordinal = ByteBufCodecs.VAR_INT.decode(buffer);
if (ordinal < 0 || ordinal >= Kind.values().length) {
throw new IllegalArgumentException("Unknown writing entry kind ordinal " + ordinal);
}
Kind kind = Kind.values()[ordinal];
return kind == Kind.SYMBOL
? symbol(SymbolReference.STREAM_CODEC.decode(buffer))
: linkPanel();
}
public enum Kind {
SYMBOL,
LINK_PANEL
}
}
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more