From 114a6ee21ef1638215fdabe3d8b9f4e6108bf9bf Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Tue, 29 Sep 2026 23:12:35 -0400 Subject: [PATCH] Streamline GameTests and writing workstation logic --- README.md | 4 + build.gradle | 23 ++++- docs/GAMETESTS.md | 42 +++++++++ docs/PREVIEW_COMPATIBILITY.md | 3 +- docs/WRITING_DATA.md | 3 +- gradle.properties | 2 +- .../block/entity/BookBinderBlockEntity.java | 17 +--- .../block/entity/InkMixerBlockEntity.java | 39 ++------ .../block/entity/WritingDeskBlockEntity.java | 40 ++------ .../gametest/BookBinderGameTests.java | 5 +- .../rivencraft/gametest/CoreGameTests.java | 94 +++++++++++++++++++ .../gametest/InkMixerGameTests.java | 4 +- .../gametest/LinkingBookRecipeGameTests.java | 3 +- .../rivencraft/gametest/LinkingGameTests.java | 6 +- .../gametest/WritingDataGameTests.java | 6 +- .../gametest/WritingDeskGameTests.java | 7 +- .../rivencraft/menu/WritingDeskMenu.java | 17 ++-- .../rivencraft/writing/InkVialExchange.java | 43 +++++++++ .../writing/WritingDeskCatalog.java | 4 +- 19 files changed, 251 insertions(+), 111 deletions(-) create mode 100644 docs/GAMETESTS.md create mode 100644 src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java create mode 100644 src/main/java/net/dnorberg/rivencraft/writing/InkVialExchange.java diff --git a/README.md b/README.md index 0186219..bf1af70 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,10 @@ known upstream limits are documented in ``` The built mod is written to `build/libs/`. +The default dedicated-server GameTest run is deliberately small. Use +`./gradlew runGameTestServer -PwithArchivedGameTests=true` for the complete +regression suite before a release or when investigating a related issue. See +[docs/GAMETESTS.md](docs/GAMETESTS.md). ## Versioning diff --git a/build.gradle b/build.gradle index 54e8f88..e89aa0b 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,18 @@ tasks.named('wrapper', Wrapper).configure { version = mod_version group = mod_group_id +List archivedGameTestSuites = ['linking', 'writing', 'desk', 'binder', 'mixer', 'recipe'] +List 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,6 +38,15 @@ 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' @@ -89,7 +110,7 @@ neoForge { // 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', project.mod_id + systemProperty 'neoforge.enabledGameTestNamespaces', enabledGameTestNamespaces } data { diff --git a/docs/GAMETESTS.md b/docs/GAMETESTS.md new file mode 100644 index 0000000..e2845d6 --- /dev/null +++ b/docs/GAMETESTS.md @@ -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_` 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. diff --git a/docs/PREVIEW_COMPATIBILITY.md b/docs/PREVIEW_COMPATIBILITY.md index a3aa0ca..cc42fc0 100644 --- a/docs/PREVIEW_COMPATIBILITY.md +++ b/docs/PREVIEW_COMPATIBILITY.md @@ -66,7 +66,7 @@ unstable in a particular graphics or mod configuration. For preview-related releases: -1. Run `./gradlew runGameTestServer` to verify the complete selection matrix, +1. Run `./gradlew runGameTestServer -PwithArchivedGameTests=true` to verify the complete selection matrix, core Age creation, persistence, and travel without optional renderers. 2. Open held Linking and Descriptive Books once with each available backend. 3. Open both book types from a Book Stand and confirm Explore is disabled. @@ -75,4 +75,3 @@ For preview-related releases: preview loader remains active. 5. With Ponder, reopen different destinations and confirm the previous snapshot does not bleed into the next one. - diff --git a/docs/WRITING_DATA.md b/docs/WRITING_DATA.md index e9410b0..13e8678 100644 --- a/docs/WRITING_DATA.md +++ b/docs/WRITING_DATA.md @@ -145,7 +145,8 @@ saved inputs are never silently discarded. Missing symbol IDs survive page, Notebook, Binder, and assembled-book persistence instead of being substituted or erased. -Dedicated-server GameTests exercise codec and item round-trips, legacy data, +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 diff --git a/gradle.properties b/gradle.properties index 673f14f..15c40fe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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. 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.4.0.0 +mod_version=0.4.0.2 immersive_portals_version=6.0.7 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java index d267d06..3b7f817 100644 --- a/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java @@ -195,26 +195,19 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe if (pages.isEmpty()) return ItemStack.EMPTY; int end = nextPageIndex(); if (index < 0 || index > end) return ItemStack.EMPTY; - boolean link = false; + ItemStack linkPanel = ItemStack.EMPTY; List 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 (link || !items.get(LINK_PANEL).isEmpty()) return ItemStack.EMPTY; - link = true; + 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() && !link) return ItemStack.EMPTY; - if (link) { - for (ItemStack page : pages) { - if (!page.isEmpty() && PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) { - items.set(LINK_PANEL, page.copy()); - break; - } - } - } + 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()); diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java index 3464ff4..7889fb7 100644 --- a/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java @@ -1,6 +1,7 @@ package net.dnorberg.rivencraft.block.entity; import net.dnorberg.rivencraft.item.InkVialItem; +import net.dnorberg.rivencraft.writing.InkVialExchange; import net.dnorberg.rivencraft.registry.ModBlockEntities; import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.writing.WritingInkService; @@ -64,35 +65,21 @@ public final class InkMixerBlockEntity extends BlockEntity implements Container worldPosition.getZ() + 0.5) <= 64.0; } - private boolean hasBottleSpace() { - ItemStack bottles = items.get(BOTTLES); - return bottles.isEmpty() || bottles.is(Items.GLASS_BOTTLE) - && bottles.getCount() < bottles.getMaxStackSize(); - } - /** No server tick: refill only when ink, bottle space, or consumption changes. */ private void refillEmptyBasin() { - if (level == null || level.isClientSide || inkRemaining != 0 || !hasBottleSpace()) return; - ItemStack vial = items.get(INK); - if (!vial.is(ModItems.INK_VIAL.get())) return; - int amount = Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY); - if (amount == 0) return; - inkRemaining = amount; - vial.shrink(1); - if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY); - ItemStack bottles = items.get(BOTTLES); - if (bottles.isEmpty()) items.set(BOTTLES, new ItemStack(Items.GLASS_BOTTLE)); - else bottles.grow(1); - setChanged(); + 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(); - if (inkRemaining >= cost) return true; - ItemStack vial = items.get(INK); - return vial.is(ModItems.INK_VIAL.get()) && hasBottleSpace() - && inkRemaining + InkVialItem.remaining(vial) >= cost; + return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, cost); } /** 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; int cost = WritingInkService.linkPanelCost(); if (inkRemaining < cost) { - ItemStack vial = items.get(INK); - inkRemaining += Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY); - vial.shrink(1); - if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY); - ItemStack bottles = items.get(BOTTLES); - if (bottles.isEmpty()) items.set(BOTTLES, new ItemStack(Items.GLASS_BOTTLE)); - else bottles.grow(1); + inkRemaining += InkVialExchange.exchange(items, INK, BOTTLES); } inkRemaining -= cost; items.get(PAPER).shrink(1); diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java index 2355c70..11f75e0 100644 --- a/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java @@ -1,6 +1,7 @@ package net.dnorberg.rivencraft.block.entity; import net.dnorberg.rivencraft.item.InkVialItem; +import net.dnorberg.rivencraft.writing.InkVialExchange; import net.dnorberg.rivencraft.writing.WritingInkService; import net.dnorberg.rivencraft.registry.ModBlockEntities; import net.dnorberg.rivencraft.registry.ModItems; @@ -92,26 +93,15 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain }; } - private boolean hasBottleSpace() { - ItemStack returned = items.get(RETURN); - return returned.isEmpty() || returned.is(Items.GLASS_BOTTLE) - && returned.getCount() < returned.getMaxStackSize(); - } - /** Consume one vial only when the empty meter can return its glass bottle. */ private void refillEmptyInkBar() { - if (level == null || level.isClientSide || inkRemaining != 0 || !hasBottleSpace()) return; - ItemStack vial = items.get(INK); - if (!vial.is(ModItems.INK_VIAL.get())) return; - int ink = Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY); - if (ink == 0) return; - inkRemaining = ink; - vial.shrink(1); - if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY); - ItemStack returned = items.get(RETURN); - if (returned.isEmpty()) items.set(RETURN, new ItemStack(Items.GLASS_BOTTLE)); - else returned.grow(1); - setChanged(); + if (level != null && !level.isClientSide && inkRemaining == 0) { + int addedInk = InkVialExchange.exchange(items, INK, RETURN); + if (addedInk > 0) { + inkRemaining = addedInk; + setChanged(); + } + } } /** The output is virtual; inputs are committed only after a result can be given to a player. */ @@ -119,23 +109,14 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain ItemStack paper = items.get(PAPER); if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false; int cost = WritingInkService.symbolCopyCost(); - if (inkRemaining >= cost) return true; - ItemStack vial = items.get(INK); - return vial.is(ModItems.INK_VIAL.get()) && inkRemaining + InkVialItem.remaining(vial) >= cost - && hasBottleSpace(); + return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost); } public boolean consumeCopyMaterials() { if (!canConsumeCopyMaterials()) return false; int cost = WritingInkService.symbolCopyCost(); if (inkRemaining < cost) { - ItemStack vial = items.get(INK); - inkRemaining += Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY); - vial.shrink(1); - if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY); - ItemStack returned = items.get(RETURN); - if (returned.isEmpty()) items.set(RETURN, new ItemStack(Items.GLASS_BOTTLE)); - else returned.grow(1); + inkRemaining += InkVialExchange.exchange(items, INK, RETURN); } inkRemaining -= cost; removeItem(PAPER, 1); @@ -148,7 +129,6 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain refillEmptyInkBar(); } @Override public void clearContent() { - items.clear(); items = NonNullList.withSize(SIZE, ItemStack.EMPTY); inkRemaining = 0; displayPage = ItemStack.EMPTY; diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java index 5b06da4..e887584 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java @@ -28,7 +28,7 @@ import net.minecraft.world.phys.AABB; import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; -@GameTestHolder(Rivencraft.MOD_ID) +@GameTestHolder("rivencraft_archive_binder") @PrefixGameTestTemplate(false) public final class BookBinderGameTests { private BookBinderGameTests() {} @@ -74,7 +74,6 @@ public final class BookBinderGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void missingSymbolPageSurvivesReloadButCannotBeAssembled(GameTestHelper helper) { BookBinderBlockEntity binder = place(helper); binder.setTitle("Recoverable Writing"); @@ -93,7 +92,6 @@ public final class BookBinderGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) { BookBinderBlockEntity binder = place(helper); binder.setTitle("A New Age"); @@ -256,7 +254,6 @@ public final class BookBinderGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void forgedBinderActionsAndReplayedOutputCannotDuplicateBook(GameTestHelper helper) { BookBinderBlockEntity binder = place(helper); binder.setTitle("Single Assembly"); diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java new file mode 100644 index 0000000..e0dd2e5 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java @@ -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); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java index 269e909..bacbe4b 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java @@ -1,6 +1,5 @@ package net.dnorberg.rivencraft.gametest; -import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; import net.dnorberg.rivencraft.menu.InkMixerMenu; import net.dnorberg.rivencraft.registry.ModBlocks; @@ -18,7 +17,7 @@ import net.minecraft.world.phys.AABB; import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; -@GameTestHolder(Rivencraft.MOD_ID) +@GameTestHolder("rivencraft_archive_mixer") @PrefixGameTestTemplate(false) public final class InkMixerGameTests { private InkMixerGameTests() {} @@ -29,7 +28,6 @@ public final class InkMixerGameTests { return (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos); } - @GameTest(template = "empty") public static void linkPanelRequiresTakingOutputAndPersistsInk(GameTestHelper helper) { InkMixerBlockEntity mixer = place(helper); mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2)); diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingBookRecipeGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingBookRecipeGameTests.java index 9c67988..4d815bb 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingBookRecipeGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingBookRecipeGameTests.java @@ -19,12 +19,11 @@ import net.minecraft.world.item.crafting.RecipeType; import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; -@GameTestHolder(Rivencraft.MOD_ID) +@GameTestHolder("rivencraft_archive_recipe") @PrefixGameTestTemplate(false) public final class LinkingBookRecipeGameTests { private LinkingBookRecipeGameTests() {} - @GameTest(template = "empty") public static void legacyRecipeRequiresExactlyLeatherAndLinkPanel(GameTestHelper helper) { LinkingBookRecipe recipe = new LinkingBookRecipe(CraftingBookCategory.MISC); ItemStack panel = PageItem.createLinkPanelPage(); diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index 1de8894..4b58e3d 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -34,7 +34,7 @@ import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; /** Server-side regression coverage for persistent linking-book data and travel safety. */ -@GameTestHolder(Rivencraft.MOD_ID) +@GameTestHolder("rivencraft_archive_linking") @PrefixGameTestTemplate(false) public final class LinkingGameTests { private static final BlockPos ORIGIN = new BlockPos(0, 1, 0); @@ -42,7 +42,6 @@ public final class LinkingGameTests { private LinkingGameTests() { } - @GameTest(template = "empty") public static void previewBackendSelectionCoversOptionalDependencyMatrix(GameTestHelper helper) { assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, true, PreviewBackendSelector.Backend.IMMERSIVE_PORTALS); @@ -127,7 +126,6 @@ public final class LinkingGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void linkTargetSurvivesItemSerialization(GameTestHelper helper) { LinkTarget expected = new LinkTarget( GlobalPos.of(helper.getLevel().dimension(), helper.absolutePos(ORIGIN)), @@ -181,7 +179,6 @@ public final class LinkingGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void ageDefinitionIsCreatedOnceWithDeterministicData(GameTestHelper helper) { UUID ageId = UUID.randomUUID(); String requestedName = "Test Age " + ageId; @@ -666,7 +663,6 @@ public final class LinkingGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void travelRejectsBlockedDestination(GameTestHelper helper) { BlockPos absoluteOrigin = helper.absolutePos(ORIGIN); for (BlockPos position : BlockPos.betweenClosed( diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java index 7c53824..f487b66 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java @@ -4,7 +4,6 @@ import com.mojang.serialization.JsonOps; import io.netty.buffer.Unpooled; import java.util.List; import java.util.UUID; -import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.PageContent; @@ -31,7 +30,7 @@ import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; /** Acceptance coverage for Milestone 4's persistent writing contracts. */ -@GameTestHolder(Rivencraft.MOD_ID) +@GameTestHolder("rivencraft_archive_writing") @PrefixGameTestTemplate(false) public final class WritingDataGameTests { private WritingDataGameTests() { @@ -62,7 +61,6 @@ public final class WritingDataGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void oldBareListWritingAndPageFreeBooksRemainReadable(GameTestHelper helper) { OrderedWriting writing = new OrderedWriting(List.of( WritingEntry.linkPanel(), WritingEntry.symbol(reference("example:removed_symbol")))); @@ -126,7 +124,6 @@ public final class WritingDataGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void pageItemPreservesContentAndRejectsForgedUnknownSymbols(GameTestHelper helper) { SymbolReference known = reference("rivencraft:terrain/standard"); SymbolReference unknown = reference("example:forged_symbol"); @@ -156,7 +153,6 @@ public final class WritingDataGameTests { } } - @GameTest(template = "empty") public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) { ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard")); first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDeskGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDeskGameTests.java index 1b3f1f9..6dc6dc6 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDeskGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDeskGameTests.java @@ -1,7 +1,6 @@ package net.dnorberg.rivencraft.gametest; import java.util.List; -import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.WritingDeskBlock; import net.dnorberg.rivencraft.menu.WritingDeskMenu; @@ -26,7 +25,7 @@ import net.minecraft.world.phys.AABB; import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; -@GameTestHolder(Rivencraft.MOD_ID) +@GameTestHolder("rivencraft_archive_desk") @PrefixGameTestTemplate(false) public final class WritingDeskGameTests { private WritingDeskGameTests() {} @@ -94,7 +93,6 @@ public final class WritingDeskGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(GameTestHelper helper) { BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3); @@ -256,7 +254,6 @@ public final class WritingDeskGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void forgedDeskSelectionsAndStaleMenuCannotCopy(GameTestHelper helper) { BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3); @@ -339,7 +336,6 @@ public final class WritingDeskGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void twoBlockDeskSharesOneInventoryAndBreaksTogether(GameTestHelper helper) { BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); var block = ModBlocks.WRITING_DESK.get(); @@ -462,7 +458,6 @@ public final class WritingDeskGameTests { helper.succeed(); } - @GameTest(template = "empty") public static void oldDeskInventoryMigratesAndAll88TabsPersist(GameTestHelper helper) { BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); var registries = helper.getLevel().registryAccess(); diff --git a/src/main/java/net/dnorberg/rivencraft/menu/WritingDeskMenu.java b/src/main/java/net/dnorberg/rivencraft/menu/WritingDeskMenu.java index c66ac4c..a8616aa 100644 --- a/src/main/java/net/dnorberg/rivencraft/menu/WritingDeskMenu.java +++ b/src/main/java/net/dnorberg/rivencraft/menu/WritingDeskMenu.java @@ -166,15 +166,12 @@ public final class WritingDeskMenu extends AbstractContainerMenu { selectedPage = page; selectedSymbol = null; } else if (id >= CATALOG_BUTTON_BASE && id < CATALOG_BUTTON_BASE + catalog.size()) { - if (!catalog.equals(WritingDeskCatalog.available(blockEntity))) { - refreshCatalog(); + List available = WritingDeskCatalog.available(blockEntity); + if (!catalog.equals(available)) { + refreshCatalog(available); return false; } - ResourceLocation requested = catalog.get(id - CATALOG_BUTTON_BASE).id(); - if (!WritingDeskCatalog.available(blockEntity).stream().anyMatch(entry -> entry.id().equals(requested))) { - return false; - } - selectedSymbol = requested; + selectedSymbol = available.get(id - CATALOG_BUTTON_BASE).id(); } else return false; refreshOutput(); return true; @@ -390,7 +387,11 @@ public final class WritingDeskMenu extends AbstractContainerMenu { private void refreshCatalog() { if (clientSide || blockEntity == null) return; - List current = WritingDeskCatalog.available(blockEntity); + refreshCatalog(WritingDeskCatalog.available(blockEntity)); + } + + private void refreshCatalog(List current) { + if (clientSide || blockEntity == null) return; if (!current.equals(catalog)) { catalog = current; if (serverViewer != null && serverViewer.containerMenu == this) { diff --git a/src/main/java/net/dnorberg/rivencraft/writing/InkVialExchange.java b/src/main/java/net/dnorberg/rivencraft/writing/InkVialExchange.java new file mode 100644 index 0000000..51c2b1c --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/writing/InkVialExchange.java @@ -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 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 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 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); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/writing/WritingDeskCatalog.java b/src/main/java/net/dnorberg/rivencraft/writing/WritingDeskCatalog.java index dab7d77..7b57ac7 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/WritingDeskCatalog.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/WritingDeskCatalog.java @@ -2,9 +2,9 @@ package net.dnorberg.rivencraft.writing; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.TreeMap; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.registry.ModDataComponents; @@ -20,7 +20,7 @@ public final class WritingDeskCatalog { private WritingDeskCatalog() {} public static List available(WritingDeskBlockEntity desk) { - Map unique = new TreeMap<>(); + Map 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);