Compare commits

...
1 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
19 changed files with 251 additions and 111 deletions

No files matched your search

+4
View File
@@ -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.
+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.
+2 -1
View File
@@ -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 Notebook, Binder, and assembled-book persistence instead of being substituted
or erased. 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, workstation NBT reload, break drops, full-inventory refusal, forged menu IDs,
and replayed output requests. World saves and chunk unloads use the same block 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 entity NBT persistence path as those reload tests. This does not replace a
+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.4.0.0 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.
@@ -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,6 @@ public final class BookBinderGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void missingSymbolPageSurvivesReloadButCannotBeAssembled(GameTestHelper helper) { public static void missingSymbolPageSurvivesReloadButCannotBeAssembled(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper); BookBinderBlockEntity binder = place(helper);
binder.setTitle("Recoverable Writing"); binder.setTitle("Recoverable Writing");
@@ -93,7 +92,6 @@ public final class BookBinderGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
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");
@@ -256,7 +254,6 @@ public final class BookBinderGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void forgedBinderActionsAndReplayedOutputCannotDuplicateBook(GameTestHelper helper) { public static void forgedBinderActionsAndReplayedOutputCannotDuplicateBook(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper); BookBinderBlockEntity binder = place(helper);
binder.setTitle("Single Assembly"); binder.setTitle("Single Assembly");
@@ -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;
@@ -18,7 +17,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_mixer")
@PrefixGameTestTemplate(false) @PrefixGameTestTemplate(false)
public final class InkMixerGameTests { public final class InkMixerGameTests {
private InkMixerGameTests() {} private InkMixerGameTests() {}
@@ -29,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));
@@ -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(
@@ -4,7 +4,6 @@ import com.mojang.serialization.JsonOps;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeReference; 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;
@@ -31,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() {
@@ -62,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"))));
@@ -126,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");
@@ -156,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);
@@ -256,7 +254,6 @@ public final class WritingDeskGameTests {
helper.succeed(); helper.succeed();
} }
@GameTest(template = "empty")
public static void forgedDeskSelectionsAndStaleMenuCannotCopy(GameTestHelper helper) { public static void forgedDeskSelectionsAndStaleMenuCannotCopy(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);
@@ -339,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();
@@ -462,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);