Complete Milestone 4 writing acceptance and migration coverage (#26, #27)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-29 22:51:49 -04:00
1 parent ba2faace15
commit 8246640f49
9 files changed
+165 -12

No files matched your search

@@ -105,7 +105,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|| items.get(LINK_PANEL).getCount() != 1) return false;
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
ItemStack page = items.get(slot);
if (!page.isEmpty() && (!validPage(page) || page.getCount() != 1
if (!page.isEmpty() && (!validPage(page) || !PageItem.isUsableWriting(page) || page.getCount() != 1
|| PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false;
}
return true;
@@ -74,6 +74,25 @@ public final class BookBinderGameTests {
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();
}
@GameTest(template = "empty")
public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
@@ -237,6 +256,39 @@ public final class BookBinderGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void forgedBinderActionsAndReplayedOutputCannotDuplicateBook(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
binder.setTitle("Single Assembly");
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
var first = helper.makeMockServerPlayerInLevel();
var second = helper.makeMockServerPlayerInLevel();
for (var player : List.of(first, second)) player.moveTo(binder.getBlockPos().getX() + 0.5,
binder.getBlockPos().getY() + 0.5, binder.getBlockPos().getZ() + 0.5);
BookBinderMenu firstMenu = new BookBinderMenu(1, first.getInventory(), binder);
BookBinderMenu secondMenu = new BookBinderMenu(2, second.getInventory(), binder);
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.SELECT_BASE + BookBinderBlockEntity.PAGE_COUNT),
"A fabricated page selection must be rejected");
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.STRIP_CLICK_BASE
+ BookBinderBlockEntity.PAGE_COUNT + 1), "A fabricated strip address must be rejected");
helper.assertTrue(firstMenu.quickMoveStack(first, BookBinderMenu.RESULT).is(ModItems.DESCRIPTIVE_BOOK.get()),
"The first viewer must receive the single assembled book");
helper.assertTrue(secondMenu.quickMoveStack(second, BookBinderMenu.RESULT).isEmpty(),
"Another viewer must not take a stale result");
helper.assertTrue(firstMenu.quickMoveStack(first, BookBinderMenu.RESULT).isEmpty(),
"Replaying the output request must not create a second book");
int books = first.getInventory().items.stream().filter(stack -> stack.is(ModItems.DESCRIPTIVE_BOOK.get()))
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(books, 1, "Only one completed book may be emitted");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).isEmpty(), "Cover must be consumed once");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty(), "Panel must be consumed once");
helper.getLevel().setBlock(binder.getBlockPos(), Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.IMPORT_NOTEBOOK),
"A stale binder menu must reject packets after block removal");
helper.succeed();
}
@GameTest(template = "empty")
public static void legacyStripInsertsAndRemovesPagesInOrder(GameTestHelper helper) {
BookBinderBlockEntity binder = place(helper);
@@ -12,7 +12,9 @@ import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@@ -98,6 +100,14 @@ public final class InkMixerGameTests {
"Shift-click must consume only one paper");
helper.getLevel().setBlock(mixer.getBlockPos(), Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(menu.stillValid(player), "Breaking the mixer must invalidate its menu");
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class,
new AABB(mixer.getBlockPos()).inflate(2));
int paper = drops.stream().filter(entity -> entity.getItem().is(Items.PAPER))
.mapToInt(entity -> entity.getItem().getCount()).sum();
int bottles = drops.stream().filter(entity -> entity.getItem().is(Items.GLASS_BOTTLE))
.mapToInt(entity -> entity.getItem().getCount()).sum();
helper.assertValueEqual(paper, 1, "Breaking the mixer must return unused paper once");
helper.assertValueEqual(bottles, 1, "Breaking the mixer must return the consumed vial's bottle once");
helper.succeed();
}
}
@@ -3,7 +3,9 @@ package net.dnorberg.rivencraft.gametest;
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;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
@@ -71,8 +73,12 @@ public final class WritingDataGameTests {
"0.3.7.2 bare-list writing must load into schema version 1 intact");
ItemStack oldPageFreeBook = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
AgeReference originalAge = new AgeReference(UUID.randomUUID(), "Legacy Page-Free Age");
oldPageFreeBook.set(ModDataComponents.AGE_REFERENCE.get(), originalAge);
CompoundTag saved = (CompoundTag) oldPageFreeBook.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), saved);
helper.assertValueEqual(restored.get(ModDataComponents.AGE_REFERENCE.get()), originalAge,
"A page-free book must keep its existing Age identity");
helper.assertValueEqual(restored.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY),
OrderedWriting.EMPTY, "Older page-free books must not require writing migration");
helper.succeed();
@@ -136,6 +142,10 @@ public final class WritingDataGameTests {
);
ItemStack recovered = PageItem.createRecoveredPage(unknown);
CompoundTag recoveredTag = (CompoundTag) recovered.saveOptional(helper.getLevel().registryAccess());
ItemStack restoredRecovered = ItemStack.parseOptional(helper.getLevel().registryAccess(), recoveredTag);
helper.assertValueEqual(PageItem.content(restoredRecovered).symbol().orElseThrow(), unknown,
"A missing symbol must survive a full Page item reload");
helper.assertFalse(PageItem.isUsableWriting(recovered),
"An unresolved page must remain recoverable without becoming valid writing");
try {
@@ -249,11 +249,40 @@ public final class WritingDeskGameTests {
"First viewer should take the last available copy");
helper.assertTrue(secondMenu.quickMoveStack(second, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Second viewer must not copy the already-consumed paper");
helper.assertTrue(firstMenu.quickMoveStack(first, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Replaying the first viewer's output request must not make another page");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
"Shared desk must spend paper only once");
helper.succeed();
}
@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);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.CATALOG_BUTTON_BASE + 999),
"A fabricated catalog index must be rejected");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + 27),
"A fabricated page address must be rejected");
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 0,
WritingDeskBlockEntity.PAPER)), "A page cannot be extracted into a material slot");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Forged actions must not spend paper");
helper.assertTrue(desk.getItem(0).is(ModItems.PAGE.get()), "Forged actions must not remove the source");
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(menu.clickMenuButton(player, 0), "A stale menu must reject selection packets");
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
"A stale menu must reject output packets");
helper.succeed();
}
@GameTest(template = "empty")
public static void catalogAggregatesKnownSymbolsAcrossCollections(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));