Compare commits

..
2 Commits
Author SHA1 Message Date
dnorberg 114a6ee21e Streamline GameTests and writing workstation logic
Build / build (push) Canceled after 0s
2026-09-29 23:12:35 -04:00
dnorberg 8246640f49 Complete Milestone 4 writing acceptance and migration coverage (#26, #27)
Build / build (push) Canceled after 0s
2026-09-29 22:51:49 -04:00
20 changed files with 411 additions and 118 deletions

No files matched your search

+6 -2
View File
@@ -4,8 +4,8 @@ Rivencraft is a clean, incremental port of the ideas and gameplay of
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
1.21.1 on NeoForge. 1.21.1 on NeoForge.
The bootstrap, books/linking, and Ages milestones are complete. Milestone 4 is The bootstrap, books/linking, Ages, and writing-system milestones are complete.
building the writing system on a data-pack symbol catalog and persistent The writing system uses a data-pack symbol catalog and persistent
ordered-writing model. The project currently provides functional Linking Books ordered-writing model. The project currently provides functional Linking Books
and randomly seeded, persistent vanilla-like Ages created from page-free and randomly seeded, persistent vanilla-like Ages created from page-free
Descriptive Books. Instability and full symbol-to-world grammar interpretation Descriptive Books. Instability and full symbol-to-world grammar interpretation
@@ -189,6 +189,10 @@ known upstream limits are documented in
``` ```
The built mod is written to `build/libs/`. 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 ## Versioning
+22 -1
View File
@@ -17,6 +17,18 @@ tasks.named('wrapper', Wrapper).configure {
version = mod_version version = mod_version
group = mod_group_id group = mod_group_id
List<String> archivedGameTestSuites = ['linking', 'writing', 'desk', 'binder', 'mixer', 'recipe']
List<String> selectedArchivedGameTestSuites = providers.gradleProperty('withArchivedGameTests')
.map { it.toBoolean() }.orElse(false).get() ? archivedGameTestSuites
: providers.gradleProperty('archivedGameTestSuites').orElse('').get()
.split(',').collect { it.trim() }.findAll { !it.isEmpty() }
if (!archivedGameTestSuites.containsAll(selectedArchivedGameTestSuites)) {
throw new GradleException("Unknown archived GameTest suite. Choose from: ${archivedGameTestSuites.join(', ')}")
}
String enabledGameTestNamespaces = ([mod_id] + selectedArchivedGameTestSuites.collect {
"rivencraft_archive_${it}"
}).join(',')
sourceSets.main.resources { sourceSets.main.resources {
// Include resources generated by data generators. // Include resources generated by data generators.
srcDir('src/generated/resources') srcDir('src/generated/resources')
@@ -26,6 +38,15 @@ sourceSets.main.resources {
exclude("src/generated/**/.cache") // datagen cache files 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 { repositories {
maven { maven {
name = 'JaredsMaven' name = 'JaredsMaven'
@@ -89,7 +110,7 @@ neoForge {
// Keep generated test Ages separate from worlds used by the dev client/server. // Keep generated test Ages separate from worlds used by the dev client/server.
gameDirectory = project.file(project.findProperty('gameTestRunDir') gameDirectory = project.file(project.findProperty('gameTestRunDir')
?: layout.buildDirectory.dir('gametest-run').get().asFile) ?: layout.buildDirectory.dir('gametest-run').get().asFile)
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id systemProperty 'neoforge.enabledGameTestNamespaces', enabledGameTestNamespaces
} }
data { data {
+42
View File
@@ -0,0 +1,42 @@
# GameTest suites
The default dedicated-server run is a small smoke suite: 16 Rivencraft tests
cover core linking, deterministic Age definition, writing persistence, legacy
data migration, workstation transactions, and forged/replayed menu actions.
NeoForge currently adds two of its own tests, so the runner reports 18.
```shell
./gradlew runGameTestServer
```
The remaining 46 Rivencraft tests are archived, not deleted. They cover
completed milestone details such as every Age terrain profile, Notebook and
Desk surface interactions, refill edge cases, book assembly controls, and
workstation break/drop behavior. Run only the relevant archive alongside the
core tests when investigating an issue:
```shell
./gradlew runGameTestServer -ParchivedGameTestSuites=desk
./gradlew runGameTestServer -ParchivedGameTestSuites=linking,binder
```
Available suite names are `linking`, `writing`, `desk`, `binder`, `mixer`, and
`recipe`. Run all 62 Rivencraft tests (64 including NeoForge's two) before a
release or after broad cross-system changes:
```shell
./gradlew runGameTestServer -PwithArchivedGameTests=true
```
Each archive is selected by its `rivencraft_archive_<suite>` GameTest namespace.
The suites share the normal empty test structure and do not need Create, Ponder, or
Immersive Portals. The core suite delegates to the original regression methods
for its selected cases, so the two suites do not maintain duplicate test logic.
GameTest Ages are created in `build/gametest-run/world`, not in a development
client save. The Gradle task removes that **dedicated** world automatically if
it exceeds 512 MiB; override the threshold with `-PgameTestMaxWorldMiB=128`,
for example. Cleanup never targets a custom `-PgameTestRunDir` or a player
world. A fresh test world can also help diagnose a slow or stalled worldgen
run. The full suite should still be run before shipping changes in the
archived systems.
+4 -3
View File
@@ -35,7 +35,8 @@ acceptance issue and all required implementation issues are complete.
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8). [Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
4. **Writing system** — pages, notebooks, desks, book assembly, and the baseline 4. **Writing system** — pages, notebooks, desks, book assembly, and the baseline
Ink Mixer for Link Panels. Symbol effects are deferred until Milestone 5. Ink Mixer for Link Panels. Symbol effects are deferred until Milestone 5.
**In progress.** **Complete.** See [Milestone 4](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/9)
and [writing-system data and migration](WRITING_DATA.md).
5. **Age grammar** — translate symbol combinations into modern biome, noise, 5. **Age grammar** — translate symbol combinations into modern biome, noise,
dimension-type, and world-generation configuration. Optional integrations dimension-type, and world-generation configuration. Optional integrations
may add composable biome regions and seasonal behavior. may add composable biome regions and seasonal behavior.
@@ -63,11 +64,11 @@ The writing system and Age grammar will build on the existing page-free Age
path. Page-free books remain a useful deterministic fallback and test fixture path. Page-free books remain a useful deterministic fallback and test fixture
after written symbol combinations become available. after written symbol combinations become available.
Milestone 4 begins with a versioned, data-pack-defined symbol catalog and an Milestone 4 delivered a versioned, data-pack-defined symbol catalog and an
immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These
contracts preserve unknown symbol identifiers and store author intent; they do contracts preserve unknown symbol identifiers and store author intent; they do
not interpret symbols into Age generation, which remains Milestone 5 work. not interpret symbols into Age generation, which remains Milestone 5 work.
The Ink Mixer is included in Milestone 4 after book assembly; its first release The Ink Mixer is included in Milestone 4 after book assembly; it
uses paper and Ink Vials to make baseline Link Panels. Adding modifier uses paper and Ink Vials to make baseline Link Panels. Adding modifier
ingredients and symbol-driven link properties belongs to Milestone 5. ingredients and symbol-driven link properties belongs to Milestone 5.
+1 -2
View File
@@ -66,7 +66,7 @@ unstable in a particular graphics or mod configuration.
For preview-related releases: For preview-related releases:
1. Run `./gradlew runGameTestServer` to verify the complete selection matrix, 1. Run `./gradlew runGameTestServer -PwithArchivedGameTests=true` to verify the complete selection matrix,
core Age creation, persistence, and travel without optional renderers. core Age creation, persistence, and travel without optional renderers.
2. Open held Linking and Descriptive Books once with each available backend. 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. 3. Open both book types from a Book Stand and confirm Explore is disabled.
@@ -75,4 +75,3 @@ For preview-related releases:
preview loader remains active. preview loader remains active.
5. With Ponder, reopen different destinations and confirm the previous 5. With Ponder, reopen different destinations and confirm the previous
snapshot does not bleed into the next one. snapshot does not bleed into the next one.
+57 -5
View File
@@ -65,11 +65,11 @@ unresolved symbol, and baseline link panel. Symbol and unresolved-symbol pages
retain their namespaced symbol reference; unresolved pages can be inspected and retain their namespaced symbol reference; unresolved pages can be inspected and
removed but are rejected as usable writing until their data pack returns. removed but are rejected as usable writing until their data pack returns.
Only server-side writing operations may create a known symbol Only server-side writing operations may create a known symbol page or link
page or link panel from vanilla paper. The shared `PageItem` creation and validation gates reject panel from vanilla paper. The shared `PageItem` creation and validation gates
new pages for identifiers absent from the server catalog. Later Writing Desk reject new pages for identifiers absent from the server catalog. The Writing
and Book Binder packets must call these gates rather than accepting page Desk refuses to copy an unresolved page, and the Book Binder keeps such a page
contents supplied by a client. removable but will not assemble a book from it until its symbol resolves again.
The item model uses server-assigned custom-model data derived from the symbol The item model uses server-assigned custom-model data derived from the symbol
identifier. Built-in pages reproduce Mystcraft Legacy's four-word poem identifier. Built-in pages reproduce Mystcraft Legacy's four-word poem
@@ -84,6 +84,29 @@ placement. The surface draws the native 30×40 portrait page region from
Legacy's book UI atlas; symbols are layered at a uniform scale, while the Legacy's book UI atlas; symbols are layered at a uniform scale, while the
square item models remain unchanged in inventories. square item models remain unchanged in inventories.
A Notebook holds 27 ordered positions. Its pages, names, gaps, and unresolved
IDs are saved on the item, so moving the Notebook between an inventory, Desk,
and Binder does not rearrange them. A loose Page in a Desk tab exposes just one
position; a Notebook exposes all 27. The Desk has 88 persistent collection
tabs, shown four at a time. Use the up/down tab controls to move by four, put
paper and Ink Vials in their input slots, select a stored page, and take the
copy from the virtual output. Taking it spends one paper and the configured
symbol-copy ink cost. The `AZ`, `ALL`, search, and category controls only change
which stored pages are displayed; the server rechecks the actual source before
copying. Moving a page within the Desk or dragging it into the inventory removes
the original only when the destination accepts it.
The Book Binder takes a leather or empty-Notebook cover, an Age title, one Link
Panel, and optionally up to 27 ordered written Pages. A filled Notebook can be
imported at a chosen strip position; the empty Notebook remains recoverable.
The preview result is virtual. Taking the completed Descriptive Book commits the
inputs once, creates its Age identity, and saves schema-versioned
`OrderedWriting` plus the Age reference on the book. Link-Panel-only books
create random Ages. A written symbol does **not** yet change that generation.
Inputs, title, and pending Age identity survive Binder save/load; breaking the
Binder returns stored inputs. The completed book retains both its writing and
Age identity through item save/load and copying.
## Ink and baseline link panels ## Ink and baseline link panels
An Ink Vial is crafted from two black dye, a glass bottle, and a water bucket. An Ink Vial is crafted from two black dye, a glass bottle, and a water bucket.
@@ -103,3 +126,32 @@ resolved page, then spends paper and ink only when that copy is taken. The
desk's 16-unit ink meter refills from an Ink Vial when needed and returns a desk's 16-unit ink meter refills from an Ink Vial when needed and returns a
vanilla glass bottle. Link-property modifiers are vanilla glass bottle. Link-property modifiers are
deferred beyond this baseline flow. deferred beyond this baseline flow.
The Ink Mixer is the station version of Link Panel preparation. Put paper and
Ink Vials in its inputs and take the Link Panel from its virtual output. Its
16-unit basin refills only when needed and only if the glass-bottle output has
space. The basin and inputs persist through save/load; breaking the Mixer
returns stored items. A Link Panel plus leather also crafts an unwritten
Linking Book. JEI, when installed, displays the Mixer operation and the
writing, binding, and Linking Book crafting flows; it is not required to play.
## Migration and validation
The 0.3.7.2 bare-list `OrderedWriting` form reads as schema version 1 without
changing entry order. Descriptive Books with no writing component keep their
existing Age behavior. Old eight-slot Desks migrate their four collections,
ink supply, paper, bottle return, and legacy output into the 88-tab layout;
saved inputs are never silently discarded. Missing symbol IDs survive page,
Notebook, Binder, and assembled-book persistence instead of being substituted
or erased.
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.
+1 -1
View File
@@ -36,7 +36,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.3.9.2 mod_version=0.4.0.2
immersive_portals_version=6.0.7 immersive_portals_version=6.0.7
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources. # This should match the base package used for the mod sources.
@@ -105,7 +105,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|| items.get(LINK_PANEL).getCount() != 1) return false; || items.get(LINK_PANEL).getCount() != 1) return false;
for (int slot = FIRST_PAGE; slot < SIZE; slot++) { for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
ItemStack page = items.get(slot); ItemStack page = items.get(slot);
if (!page.isEmpty() && (!validPage(page) || page.getCount() != 1 if (!page.isEmpty() && (!validPage(page) || !PageItem.isUsableWriting(page) || page.getCount() != 1
|| PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false; || PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false;
} }
return true; return true;
@@ -195,26 +195,19 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
if (pages.isEmpty()) return ItemStack.EMPTY; if (pages.isEmpty()) return ItemStack.EMPTY;
int end = nextPageIndex(); int end = nextPageIndex();
if (index < 0 || index > end) return ItemStack.EMPTY; if (index < 0 || index > end) return ItemStack.EMPTY;
boolean link = false; ItemStack linkPanel = ItemStack.EMPTY;
List<ItemStack> writing = new ArrayList<>(); List<ItemStack> writing = new ArrayList<>();
for (ItemStack page : pages) { for (ItemStack page : pages) {
if (page.isEmpty()) continue; if (page.isEmpty()) continue;
if (!page.is(ModItems.PAGE.get()) || page.getCount() != 1) return ItemStack.EMPTY; if (!page.is(ModItems.PAGE.get()) || page.getCount() != 1) return ItemStack.EMPTY;
if (PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) { if (PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) {
if (link || !items.get(LINK_PANEL).isEmpty()) return ItemStack.EMPTY; if (!linkPanel.isEmpty() || !items.get(LINK_PANEL).isEmpty()) return ItemStack.EMPTY;
link = true; linkPanel = page;
} else if (validPage(page)) writing.add(page); } else if (validPage(page)) writing.add(page);
else return ItemStack.EMPTY; else return ItemStack.EMPTY;
} }
if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && !link) return ItemStack.EMPTY; if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && linkPanel.isEmpty()) return ItemStack.EMPTY;
if (link) { if (!linkPanel.isEmpty()) items.set(LINK_PANEL, linkPanel.copy());
for (ItemStack page : pages) {
if (!page.isEmpty() && PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) {
items.set(LINK_PANEL, page.copy());
break;
}
}
}
for (int current = end - 1; current >= index; current--) for (int current = end - 1; current >= index; current--)
items.set(FIRST_PAGE + current + writing.size(), items.get(FIRST_PAGE + current)); items.set(FIRST_PAGE + current + writing.size(), items.get(FIRST_PAGE + current));
for (ItemStack page : writing) items.set(FIRST_PAGE + index++, page.copy()); for (ItemStack page : writing) items.set(FIRST_PAGE + index++, page.copy());
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.block.entity; package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.item.InkVialItem; import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.registry.ModBlockEntities; import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.WritingInkService; import net.dnorberg.rivencraft.writing.WritingInkService;
@@ -64,35 +65,21 @@ public final class InkMixerBlockEntity extends BlockEntity implements Container
worldPosition.getZ() + 0.5) <= 64.0; worldPosition.getZ() + 0.5) <= 64.0;
} }
private boolean hasBottleSpace() {
ItemStack bottles = items.get(BOTTLES);
return bottles.isEmpty() || bottles.is(Items.GLASS_BOTTLE)
&& bottles.getCount() < bottles.getMaxStackSize();
}
/** No server tick: refill only when ink, bottle space, or consumption changes. */ /** No server tick: refill only when ink, bottle space, or consumption changes. */
private void refillEmptyBasin() { private void refillEmptyBasin() {
if (level == null || level.isClientSide || inkRemaining != 0 || !hasBottleSpace()) return; if (level != null && !level.isClientSide && inkRemaining == 0) {
ItemStack vial = items.get(INK); int addedInk = InkVialExchange.exchange(items, INK, BOTTLES);
if (!vial.is(ModItems.INK_VIAL.get())) return; if (addedInk > 0) {
int amount = Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY); inkRemaining = addedInk;
if (amount == 0) return; setChanged();
inkRemaining = amount; }
vial.shrink(1); }
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
ItemStack bottles = items.get(BOTTLES);
if (bottles.isEmpty()) items.set(BOTTLES, new ItemStack(Items.GLASS_BOTTLE));
else bottles.grow(1);
setChanged();
} }
public boolean canCreateLinkPanel() { public boolean canCreateLinkPanel() {
if (!items.get(PAPER).is(Items.PAPER)) return false; if (!items.get(PAPER).is(Items.PAPER)) return false;
int cost = WritingInkService.linkPanelCost(); int cost = WritingInkService.linkPanelCost();
if (inkRemaining >= cost) return true; return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, cost);
ItemStack vial = items.get(INK);
return vial.is(ModItems.INK_VIAL.get()) && hasBottleSpace()
&& inkRemaining + InkVialItem.remaining(vial) >= cost;
} }
/** Commit inputs only when a virtual output is actually taken. */ /** Commit inputs only when a virtual output is actually taken. */
@@ -100,13 +87,7 @@ public final class InkMixerBlockEntity extends BlockEntity implements Container
if (!canCreateLinkPanel()) return false; if (!canCreateLinkPanel()) return false;
int cost = WritingInkService.linkPanelCost(); int cost = WritingInkService.linkPanelCost();
if (inkRemaining < cost) { if (inkRemaining < cost) {
ItemStack vial = items.get(INK); inkRemaining += InkVialExchange.exchange(items, INK, BOTTLES);
inkRemaining += Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
vial.shrink(1);
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
ItemStack bottles = items.get(BOTTLES);
if (bottles.isEmpty()) items.set(BOTTLES, new ItemStack(Items.GLASS_BOTTLE));
else bottles.grow(1);
} }
inkRemaining -= cost; inkRemaining -= cost;
items.get(PAPER).shrink(1); items.get(PAPER).shrink(1);
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.block.entity; package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.item.InkVialItem; import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.WritingInkService; import net.dnorberg.rivencraft.writing.WritingInkService;
import net.dnorberg.rivencraft.registry.ModBlockEntities; import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
@@ -92,26 +93,15 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
}; };
} }
private boolean hasBottleSpace() {
ItemStack returned = items.get(RETURN);
return returned.isEmpty() || returned.is(Items.GLASS_BOTTLE)
&& returned.getCount() < returned.getMaxStackSize();
}
/** Consume one vial only when the empty meter can return its glass bottle. */ /** Consume one vial only when the empty meter can return its glass bottle. */
private void refillEmptyInkBar() { private void refillEmptyInkBar() {
if (level == null || level.isClientSide || inkRemaining != 0 || !hasBottleSpace()) return; if (level != null && !level.isClientSide && inkRemaining == 0) {
ItemStack vial = items.get(INK); int addedInk = InkVialExchange.exchange(items, INK, RETURN);
if (!vial.is(ModItems.INK_VIAL.get())) return; if (addedInk > 0) {
int ink = Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY); inkRemaining = addedInk;
if (ink == 0) return; setChanged();
inkRemaining = ink; }
vial.shrink(1); }
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
ItemStack returned = items.get(RETURN);
if (returned.isEmpty()) items.set(RETURN, new ItemStack(Items.GLASS_BOTTLE));
else returned.grow(1);
setChanged();
} }
/** The output is virtual; inputs are committed only after a result can be given to a player. */ /** The output is virtual; inputs are committed only after a result can be given to a player. */
@@ -119,23 +109,14 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
ItemStack paper = items.get(PAPER); ItemStack paper = items.get(PAPER);
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false; if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
int cost = WritingInkService.symbolCopyCost(); int cost = WritingInkService.symbolCopyCost();
if (inkRemaining >= cost) return true; return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
ItemStack vial = items.get(INK);
return vial.is(ModItems.INK_VIAL.get()) && inkRemaining + InkVialItem.remaining(vial) >= cost
&& hasBottleSpace();
} }
public boolean consumeCopyMaterials() { public boolean consumeCopyMaterials() {
if (!canConsumeCopyMaterials()) return false; if (!canConsumeCopyMaterials()) return false;
int cost = WritingInkService.symbolCopyCost(); int cost = WritingInkService.symbolCopyCost();
if (inkRemaining < cost) { if (inkRemaining < cost) {
ItemStack vial = items.get(INK); inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
inkRemaining += Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
vial.shrink(1);
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
ItemStack returned = items.get(RETURN);
if (returned.isEmpty()) items.set(RETURN, new ItemStack(Items.GLASS_BOTTLE));
else returned.grow(1);
} }
inkRemaining -= cost; inkRemaining -= cost;
removeItem(PAPER, 1); removeItem(PAPER, 1);
@@ -148,7 +129,6 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
refillEmptyInkBar(); refillEmptyInkBar();
} }
@Override public void clearContent() { @Override public void clearContent() {
items.clear();
items = NonNullList.withSize(SIZE, ItemStack.EMPTY); items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0; inkRemaining = 0;
displayPage = ItemStack.EMPTY; displayPage = ItemStack.EMPTY;
@@ -28,7 +28,7 @@ import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID) @GameTestHolder("rivencraft_archive_binder")
@PrefixGameTestTemplate(false) @PrefixGameTestTemplate(false)
public final class BookBinderGameTests { public final class BookBinderGameTests {
private BookBinderGameTests() {} private BookBinderGameTests() {}
@@ -74,7 +74,24 @@ public final class BookBinderGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty") public static void missingSymbolPageSurvivesReloadButCannotBeAssembled(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setTitle("Recoverable Writing");
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
SymbolReference missing = new SymbolReference(ResourceLocation.parse("example:removed_symbol"));
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, PageItem.createRecoveredPage(missing));
var saved = binder.saveWithoutMetadata(helper.getLevel().registryAccess());
var restored = new BookBinderBlockEntity(binder.getBlockPos(), binder.getBlockState());
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
helper.assertValueEqual(PageItem.content(restored.getItem(BookBinderBlockEntity.FIRST_PAGE))
.symbol().orElseThrow(), missing, "Reload must preserve the removed data-pack symbol ID");
helper.assertFalse(restored.canAssemble(), "Missing symbol cannot create a new written Age");
helper.assertTrue(restored.getItem(BookBinderBlockEntity.FIRST_PAGE).is(ModItems.PAGE.get()),
"Rejected writing must remain recoverable as a Page");
helper.succeed();
}
public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) { public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper); BookBinderBlockEntity binder = place(helper);
binder.setTitle("A New Age"); binder.setTitle("A New Age");
@@ -237,6 +254,38 @@ public final class BookBinderGameTests {
helper.succeed(); 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") @GameTest(template = "empty")
public static void legacyStripInsertsAndRemovesPagesInOrder(GameTestHelper helper) { public static void legacyStripInsertsAndRemovesPagesInOrder(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper); BookBinderBlockEntity binder = place(helper);
@@ -0,0 +1,94 @@
package net.dnorberg.rivencraft.gametest;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Small default smoke suite; the detailed milestone regressions are opt-in. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class CoreGameTests {
private CoreGameTests() {}
@GameTest(template = "empty")
public static void previewBackendSelection(GameTestHelper helper) {
LinkingGameTests.previewBackendSelectionCoversOptionalDependencyMatrix(helper);
}
@GameTest(template = "empty")
public static void linkTargetPersistence(GameTestHelper helper) {
LinkingGameTests.linkTargetSurvivesItemSerialization(helper);
}
@GameTest(template = "empty")
public static void deterministicAgeDefinition(GameTestHelper helper) {
LinkingGameTests.ageDefinitionIsCreatedOnceWithDeterministicData(helper);
}
@GameTest(template = "empty")
public static void blockedLinkDestination(GameTestHelper helper) {
LinkingGameTests.travelRejectsBlockedDestination(helper);
}
@GameTest(template = "empty")
public static void legacyWritingAndPageFreeBooks(GameTestHelper helper) {
WritingDataGameTests.oldBareListWritingAndPageFreeBooksRemainReadable(helper);
}
@GameTest(template = "empty")
public static void pagePersistenceAndUnknownSymbols(GameTestHelper helper) {
WritingDataGameTests.pageItemPreservesContentAndRejectsForgedUnknownSymbols(helper);
}
@GameTest(template = "empty")
public static void notebookPersistence(GameTestHelper helper) {
WritingDataGameTests.notebookContentsPreserveOrderGapsNamesAndUnknownPages(helper);
}
@GameTest(template = "empty")
public static void deskCopyTransaction(GameTestHelper helper) {
WritingDeskGameTests.takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(helper);
}
@GameTest(template = "empty")
public static void deskRejectsForgedAndStaleActions(GameTestHelper helper) {
WritingDeskGameTests.forgedDeskSelectionsAndStaleMenuCannotCopy(helper);
}
@GameTest(template = "empty")
public static void twoBlockDeskDropsOnce(GameTestHelper helper) {
WritingDeskGameTests.twoBlockDeskSharesOneInventoryAndBreaksTogether(helper);
}
@GameTest(template = "empty")
public static void oldDeskMigration(GameTestHelper helper) {
WritingDeskGameTests.oldDeskInventoryMigratesAndAll88TabsPersist(helper);
}
@GameTest(template = "empty")
public static void binderKeepsMissingSymbolsRecoverable(GameTestHelper helper) {
BookBinderGameTests.missingSymbolPageSurvivesReloadButCannotBeAssembled(helper);
}
@GameTest(template = "empty")
public static void binderAssemblyTransaction(GameTestHelper helper) {
BookBinderGameTests.assemblyIsAtomicAndRetainsPageOrder(helper);
}
@GameTest(template = "empty")
public static void binderRejectsForgedAndReplayedActions(GameTestHelper helper) {
BookBinderGameTests.forgedBinderActionsAndReplayedOutputCannotDuplicateBook(helper);
}
@GameTest(template = "empty")
public static void mixerOutputTransaction(GameTestHelper helper) {
InkMixerGameTests.linkPanelRequiresTakingOutputAndPersistsInk(helper);
}
@GameTest(template = "empty")
public static void linkingBookRecipe(GameTestHelper helper) {
LinkingBookRecipeGameTests.legacyRecipeRequiresExactlyLeatherAndLinkPanel(helper);
}
}
@@ -1,6 +1,5 @@
package net.dnorberg.rivencraft.gametest; package net.dnorberg.rivencraft.gametest;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.InkMixerMenu; import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.registry.ModBlocks;
@@ -12,11 +11,13 @@ import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.inventory.ClickType; import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items; import net.minecraft.world.item.Items;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID) @GameTestHolder("rivencraft_archive_mixer")
@PrefixGameTestTemplate(false) @PrefixGameTestTemplate(false)
public final class InkMixerGameTests { public final class InkMixerGameTests {
private InkMixerGameTests() {} private InkMixerGameTests() {}
@@ -27,7 +28,6 @@ public final class InkMixerGameTests {
return (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos); return (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos);
} }
@GameTest(template = "empty")
public static void linkPanelRequiresTakingOutputAndPersistsInk(GameTestHelper helper) { public static void linkPanelRequiresTakingOutputAndPersistsInk(GameTestHelper helper) {
InkMixerBlockEntity mixer = place(helper); InkMixerBlockEntity mixer = place(helper);
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2)); mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
@@ -98,6 +98,14 @@ public final class InkMixerGameTests {
"Shift-click must consume only one paper"); "Shift-click must consume only one paper");
helper.getLevel().setBlock(mixer.getBlockPos(), Blocks.AIR.defaultBlockState(), 3); helper.getLevel().setBlock(mixer.getBlockPos(), Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(menu.stillValid(player), "Breaking the mixer must invalidate its menu"); 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(); helper.succeed();
} }
} }
@@ -19,12 +19,11 @@ import net.minecraft.world.item.crafting.RecipeType;
import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID) @GameTestHolder("rivencraft_archive_recipe")
@PrefixGameTestTemplate(false) @PrefixGameTestTemplate(false)
public final class LinkingBookRecipeGameTests { public final class LinkingBookRecipeGameTests {
private LinkingBookRecipeGameTests() {} private LinkingBookRecipeGameTests() {}
@GameTest(template = "empty")
public static void legacyRecipeRequiresExactlyLeatherAndLinkPanel(GameTestHelper helper) { public static void legacyRecipeRequiresExactlyLeatherAndLinkPanel(GameTestHelper helper) {
LinkingBookRecipe recipe = new LinkingBookRecipe(CraftingBookCategory.MISC); LinkingBookRecipe recipe = new LinkingBookRecipe(CraftingBookCategory.MISC);
ItemStack panel = PageItem.createLinkPanelPage(); ItemStack panel = PageItem.createLinkPanelPage();
@@ -34,7 +34,7 @@ import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Server-side regression coverage for persistent linking-book data and travel safety. */ /** Server-side regression coverage for persistent linking-book data and travel safety. */
@GameTestHolder(Rivencraft.MOD_ID) @GameTestHolder("rivencraft_archive_linking")
@PrefixGameTestTemplate(false) @PrefixGameTestTemplate(false)
public final class LinkingGameTests { public final class LinkingGameTests {
private static final BlockPos ORIGIN = new BlockPos(0, 1, 0); private static final BlockPos ORIGIN = new BlockPos(0, 1, 0);
@@ -42,7 +42,6 @@ public final class LinkingGameTests {
private LinkingGameTests() { private LinkingGameTests() {
} }
@GameTest(template = "empty")
public static void previewBackendSelectionCoversOptionalDependencyMatrix(GameTestHelper helper) { public static void previewBackendSelectionCoversOptionalDependencyMatrix(GameTestHelper helper) {
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, true, assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, true,
PreviewBackendSelector.Backend.IMMERSIVE_PORTALS); PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
@@ -127,7 +126,6 @@ public final class LinkingGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void linkTargetSurvivesItemSerialization(GameTestHelper helper) { public static void linkTargetSurvivesItemSerialization(GameTestHelper helper) {
LinkTarget expected = new LinkTarget( LinkTarget expected = new LinkTarget(
GlobalPos.of(helper.getLevel().dimension(), helper.absolutePos(ORIGIN)), GlobalPos.of(helper.getLevel().dimension(), helper.absolutePos(ORIGIN)),
@@ -181,7 +179,6 @@ public final class LinkingGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void ageDefinitionIsCreatedOnceWithDeterministicData(GameTestHelper helper) { public static void ageDefinitionIsCreatedOnceWithDeterministicData(GameTestHelper helper) {
UUID ageId = UUID.randomUUID(); UUID ageId = UUID.randomUUID();
String requestedName = "Test Age " + ageId; String requestedName = "Test Age " + ageId;
@@ -666,7 +663,6 @@ public final class LinkingGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void travelRejectsBlockedDestination(GameTestHelper helper) { public static void travelRejectsBlockedDestination(GameTestHelper helper) {
BlockPos absoluteOrigin = helper.absolutePos(ORIGIN); BlockPos absoluteOrigin = helper.absolutePos(ORIGIN);
for (BlockPos position : BlockPos.betweenClosed( for (BlockPos position : BlockPos.betweenClosed(
@@ -3,7 +3,8 @@ package net.dnorberg.rivencraft.gametest;
import com.mojang.serialization.JsonOps; import com.mojang.serialization.JsonOps;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import java.util.List; import java.util.List;
import net.dnorberg.rivencraft.Rivencraft; import java.util.UUID;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog; import net.dnorberg.rivencraft.writing.SymbolCatalog;
@@ -29,7 +30,7 @@ import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Acceptance coverage for Milestone 4's persistent writing contracts. */ /** Acceptance coverage for Milestone 4's persistent writing contracts. */
@GameTestHolder(Rivencraft.MOD_ID) @GameTestHolder("rivencraft_archive_writing")
@PrefixGameTestTemplate(false) @PrefixGameTestTemplate(false)
public final class WritingDataGameTests { public final class WritingDataGameTests {
private WritingDataGameTests() { private WritingDataGameTests() {
@@ -60,7 +61,6 @@ public final class WritingDataGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void oldBareListWritingAndPageFreeBooksRemainReadable(GameTestHelper helper) { public static void oldBareListWritingAndPageFreeBooksRemainReadable(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of( OrderedWriting writing = new OrderedWriting(List.of(
WritingEntry.linkPanel(), WritingEntry.symbol(reference("example:removed_symbol")))); WritingEntry.linkPanel(), WritingEntry.symbol(reference("example:removed_symbol"))));
@@ -71,8 +71,12 @@ public final class WritingDataGameTests {
"0.3.7.2 bare-list writing must load into schema version 1 intact"); "0.3.7.2 bare-list writing must load into schema version 1 intact");
ItemStack oldPageFreeBook = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get()); 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()); CompoundTag saved = (CompoundTag) oldPageFreeBook.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), saved); 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), helper.assertValueEqual(restored.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY),
OrderedWriting.EMPTY, "Older page-free books must not require writing migration"); OrderedWriting.EMPTY, "Older page-free books must not require writing migration");
helper.succeed(); helper.succeed();
@@ -120,7 +124,6 @@ public final class WritingDataGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void pageItemPreservesContentAndRejectsForgedUnknownSymbols(GameTestHelper helper) { public static void pageItemPreservesContentAndRejectsForgedUnknownSymbols(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard"); SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference unknown = reference("example:forged_symbol"); SymbolReference unknown = reference("example:forged_symbol");
@@ -136,6 +139,10 @@ public final class WritingDataGameTests {
); );
ItemStack recovered = PageItem.createRecoveredPage(unknown); ItemStack recovered = PageItem.createRecoveredPage(unknown);
CompoundTag recoveredTag = (CompoundTag) recovered.saveOptional(helper.getLevel().registryAccess());
ItemStack restoredRecovered = ItemStack.parseOptional(helper.getLevel().registryAccess(), recoveredTag);
helper.assertValueEqual(PageItem.content(restoredRecovered).symbol().orElseThrow(), unknown,
"A missing symbol must survive a full Page item reload");
helper.assertFalse(PageItem.isUsableWriting(recovered), helper.assertFalse(PageItem.isUsableWriting(recovered),
"An unresolved page must remain recoverable without becoming valid writing"); "An unresolved page must remain recoverable without becoming valid writing");
try { try {
@@ -146,7 +153,6 @@ public final class WritingDataGameTests {
} }
} }
@GameTest(template = "empty")
public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) { public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) {
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard")); ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
@@ -1,7 +1,6 @@
package net.dnorberg.rivencraft.gametest; package net.dnorberg.rivencraft.gametest;
import java.util.List; import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.block.WritingDeskBlock; import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.menu.WritingDeskMenu; import net.dnorberg.rivencraft.menu.WritingDeskMenu;
@@ -26,7 +25,7 @@ import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID) @GameTestHolder("rivencraft_archive_desk")
@PrefixGameTestTemplate(false) @PrefixGameTestTemplate(false)
public final class WritingDeskGameTests { public final class WritingDeskGameTests {
private WritingDeskGameTests() {} private WritingDeskGameTests() {}
@@ -94,7 +93,6 @@ public final class WritingDeskGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(GameTestHelper helper) { public static void takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3); helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
@@ -249,11 +247,39 @@ public final class WritingDeskGameTests {
"First viewer should take the last available copy"); "First viewer should take the last available copy");
helper.assertTrue(secondMenu.quickMoveStack(second, WritingDeskMenu.COPY_RESULT).isEmpty(), helper.assertTrue(secondMenu.quickMoveStack(second, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Second viewer must not copy the already-consumed paper"); "Second viewer must not copy the already-consumed paper");
helper.assertTrue(firstMenu.quickMoveStack(first, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Replaying the first viewer's output request must not make another page");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(), helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
"Shared desk must spend paper only once"); "Shared desk must spend paper only once");
helper.succeed(); helper.succeed();
} }
public static void forgedDeskSelectionsAndStaleMenuCannotCopy(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.CATALOG_BUTTON_BASE + 999),
"A fabricated catalog index must be rejected");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + 27),
"A fabricated page address must be rejected");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 0,
WritingDeskBlockEntity.PAPER)), "A page cannot be extracted into a material slot");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Forged actions must not spend paper");
helper.assertTrue(desk.getItem(0).is(ModItems.PAGE.get()), "Forged actions must not remove the source");
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(menu.clickMenuButton(player, 0), "A stale menu must reject selection packets");
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
"A stale menu must reject output packets");
helper.succeed();
}
@GameTest(template = "empty") @GameTest(template = "empty")
public static void catalogAggregatesKnownSymbolsAcrossCollections(GameTestHelper helper) { public static void catalogAggregatesKnownSymbolsAcrossCollections(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
@@ -310,7 +336,6 @@ public final class WritingDeskGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void twoBlockDeskSharesOneInventoryAndBreaksTogether(GameTestHelper helper) { public static void twoBlockDeskSharesOneInventoryAndBreaksTogether(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var block = ModBlocks.WRITING_DESK.get(); var block = ModBlocks.WRITING_DESK.get();
@@ -433,7 +458,6 @@ public final class WritingDeskGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void oldDeskInventoryMigratesAndAll88TabsPersist(GameTestHelper helper) { public static void oldDeskInventoryMigratesAndAll88TabsPersist(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var registries = helper.getLevel().registryAccess(); var registries = helper.getLevel().registryAccess();
@@ -166,15 +166,12 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
selectedPage = page; selectedPage = page;
selectedSymbol = null; selectedSymbol = null;
} else if (id >= CATALOG_BUTTON_BASE && id < CATALOG_BUTTON_BASE + catalog.size()) { } else if (id >= CATALOG_BUTTON_BASE && id < CATALOG_BUTTON_BASE + catalog.size()) {
if (!catalog.equals(WritingDeskCatalog.available(blockEntity))) { List<SymbolDisplayMetadata> available = WritingDeskCatalog.available(blockEntity);
refreshCatalog(); if (!catalog.equals(available)) {
refreshCatalog(available);
return false; return false;
} }
ResourceLocation requested = catalog.get(id - CATALOG_BUTTON_BASE).id(); selectedSymbol = available.get(id - CATALOG_BUTTON_BASE).id();
if (!WritingDeskCatalog.available(blockEntity).stream().anyMatch(entry -> entry.id().equals(requested))) {
return false;
}
selectedSymbol = requested;
} else return false; } else return false;
refreshOutput(); refreshOutput();
return true; return true;
@@ -390,7 +387,11 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
private void refreshCatalog() { private void refreshCatalog() {
if (clientSide || blockEntity == null) return; if (clientSide || blockEntity == null) return;
List<SymbolDisplayMetadata> current = WritingDeskCatalog.available(blockEntity); refreshCatalog(WritingDeskCatalog.available(blockEntity));
}
private void refreshCatalog(List<SymbolDisplayMetadata> current) {
if (clientSide || blockEntity == null) return;
if (!current.equals(catalog)) { if (!current.equals(catalog)) {
catalog = current; catalog = current;
if (serverViewer != null && serverViewer.containerMenu == this) { if (serverViewer != null && serverViewer.containerMenu == this) {
@@ -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);
}
}
@@ -2,9 +2,9 @@ package net.dnorberg.rivencraft.writing;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TreeMap;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
@@ -20,7 +20,7 @@ public final class WritingDeskCatalog {
private WritingDeskCatalog() {} private WritingDeskCatalog() {}
public static List<SymbolDisplayMetadata> available(WritingDeskBlockEntity desk) { public static List<SymbolDisplayMetadata> available(WritingDeskBlockEntity desk) {
Map<ResourceLocation, SymbolDisplayMetadata> unique = new TreeMap<>(); Map<ResourceLocation, SymbolDisplayMetadata> unique = new HashMap<>();
for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) { for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) {
ItemStack collection = desk.getItem(slot); ItemStack collection = desk.getItem(slot);
if (collection.is(ModItems.PAGE.get())) add(unique, collection); if (collection.is(ModItems.PAGE.get())) add(unique, collection);