Compare commits

...
2 Commits
Author SHA1 Message Date
dnorberg 1dcd3a178e Add Writing Desk paper copying and ink meter
Build / build (push) Canceled after 0s
2026-09-24 05:38:51 -04:00
dnorberg 385741027a Show Writing Desk page previews like Legacy
Build / build (push) Canceled after 0s
2026-09-24 05:17:31 -04:00
9 changed files with 410 additions and 31 deletions

No files matched your search

+11 -3
View File
@@ -19,12 +19,20 @@ hand and use the Ink Vial in the other to prepare a baseline Link Panel Page.
Each vial holds 16 ink units, and the default link-panel cost is two units. Each vial holds 16 ink units, and the default link-panel cost is two units.
The spent vial returns a glass bottle. Server owners can adjust the symbol-copy The spent vial returns a glass bottle. Server owners can adjust the symbol-copy
and link-panel costs in the `writing` section of `rivencraft-server.toml`. and link-panel costs in the `writing` section of `rivencraft-server.toml`.
Symbol-page transcription will become available through the Writing Desk. The Writing Desk can copy a selected written page from its collection tabs.
The Writing Desk is now craftable from planks, a glass bottle, and a feather. The Writing Desk is now craftable from planks, a glass bottle, and a feather.
Its four collection tabs, ink/source/page work slots, and inventory persist Its four collection tabs, paper input, ink vial input, empty glass-bottle return,
16-unit ink meter, and inventory persist
through world saves; breaking it drops stored contents. The desk screen is a through world saves; breaking it drops stored contents. The desk screen is a
functional foundation styled after Legacy's left tabs, central writing surface, functional foundation styled after Legacy's left tabs, central writing surface,
and right inventory. Transcription controls are not implemented yet. and right inventory. Put vanilla paper (or a blank Rivencraft Page) in the
upper-left slot and Ink Vials in the upper-right; a copy appears in the
lower-left result slot when the selected page is valid. Taking it consumes one
paper and, by default, one ink. An empty meter consumes a vial to refill and
returns a vanilla glass bottle to the lower-right slot. Writing new symbols
from a catalog remains future work.
Selecting a stored page on the desk surface shows its Legacy-proportioned
portrait preview and name above the player inventory.
## Linking Book ## Linking Book
+4 -1
View File
@@ -86,5 +86,8 @@ panel, hold a blank Page in one hand and use an Ink Vial in the other. The
server checks the held items, blank state, and sufficient ink before consuming server checks the held items, blank state, and sufficient ink before consuming
anything. A stack of blank Pages yields exactly one Link Panel Page per use; anything. A stack of blank Pages yields exactly one Link Panel Page per use;
if the inventory is full, the result is dropped instead of deleted. Symbol if the inventory is full, the result is dropped instead of deleted. Symbol
copying will be implemented by the Writing Desk. Link-property modifiers are copying is implemented by the Writing Desk: it previews a copy of the selected
resolved page, then spends paper and ink only when that copy is taken. The
desk's 16-unit ink meter refills from an Ink Vial when needed and returns a
vanilla glass bottle. Link-property modifiers are
deferred beyond this baseline flow. deferred beyond this baseline flow.
+1 -1
View File
@@ -35,7 +35,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.5.0 mod_version=0.3.5.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.
@@ -1,5 +1,8 @@
package net.dnorberg.rivencraft.block.entity; package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
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;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
@@ -10,25 +13,29 @@ import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper; import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
/** The four Legacy-like collection tabs and four work slots are owned by the block, not its viewers. */ /** Four collection tabs, paper, ink vials and empty-vial return are owned by the block. */
public final class WritingDeskBlockEntity extends BlockEntity implements Container { public final class WritingDeskBlockEntity extends BlockEntity implements Container {
public static final int COLLECTIONS = 4; public static final int COLLECTIONS = 4;
public static final int INK = 4; public static final int INK = 4;
public static final int SOURCE = 5; public static final int RETURN = 5;
public static final int PAPER = 6; public static final int PAPER = 6;
public static final int OUTPUT = 7; /** Unused by new desks; retained so old saves cannot silently lose slot contents. */
public static final int LEGACY_OUTPUT = 7;
public static final int SIZE = 8; public static final int SIZE = 8;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY); private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int activeCollection; private int activeCollection;
private int inkRemaining;
public WritingDeskBlockEntity(BlockPos pos, BlockState state) { public WritingDeskBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.WRITING_DESK.get(), pos, state); super(ModBlockEntities.WRITING_DESK.get(), pos, state);
} }
public int activeCollection() { return activeCollection; } public int activeCollection() { return activeCollection; }
public int inkRemaining() { return inkRemaining; }
public void setActiveCollection(int index) { public void setActiveCollection(int index) {
if (index >= 0 && index < COLLECTIONS && activeCollection != index) { if (index >= 0 && index < COLLECTIONS && activeCollection != index) {
activeCollection = index; activeCollection = index;
@@ -60,15 +67,49 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
if (slot < COLLECTIONS) return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.PAGE.get()); if (slot < COLLECTIONS) return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.PAGE.get());
return switch (slot) { return switch (slot) {
case INK -> stack.is(ModItems.INK_VIAL.get()); case INK -> stack.is(ModItems.INK_VIAL.get());
case SOURCE -> stack.is(ModItems.PAGE.get()) || stack.is(ModItems.NOTEBOOK.get()); case RETURN -> false;
case PAPER -> stack.is(ModItems.PAGE.get()); case PAPER -> stack.is(Items.PAPER)
case OUTPUT -> false; || stack.is(ModItems.PAGE.get()) && PageItem.content(stack).kind()
== net.dnorberg.rivencraft.writing.PageContent.Kind.BLANK;
case LEGACY_OUTPUT -> false;
default -> false; default -> false;
}; };
} }
/** The output is virtual; inputs are committed only after a result can be given to a player. */
public boolean canConsumeCopyMaterials() {
ItemStack paper = items.get(PAPER);
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
int cost = WritingInkService.symbolCopyCost();
if (inkRemaining >= cost) return true;
ItemStack vial = items.get(INK);
ItemStack returned = items.get(RETURN);
return vial.is(ModItems.INK_VIAL.get()) && inkRemaining + InkVialItem.remaining(vial) >= cost
&& (returned.isEmpty() || returned.is(Items.GLASS_BOTTLE)
&& returned.getCount() < returned.getMaxStackSize());
}
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 -= cost;
removeItem(PAPER, 1);
setChanged();
return true;
}
@Override public void clearContent() { @Override public void clearContent() {
items.clear(); items.clear();
items = NonNullList.withSize(SIZE, ItemStack.EMPTY); items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
setChanged(); setChanged();
} }
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { @Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
@@ -76,10 +117,12 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
items = NonNullList.withSize(SIZE, ItemStack.EMPTY); items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(tag, items, registries); ContainerHelper.loadAllItems(tag, items, registries);
activeCollection = Math.clamp(tag.getInt("ActiveCollection"), 0, COLLECTIONS - 1); activeCollection = Math.clamp(tag.getInt("ActiveCollection"), 0, COLLECTIONS - 1);
inkRemaining = Math.clamp(tag.getInt("InkRemaining"), 0, InkVialItem.CAPACITY);
} }
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { @Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries); super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, items, registries); ContainerHelper.saveAllItems(tag, items, registries);
tag.putInt("ActiveCollection", activeCollection); tag.putInt("ActiveCollection", activeCollection);
tag.putInt("InkRemaining", inkRemaining);
} }
} }
@@ -271,7 +271,7 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
} }
} }
private static ResourceLocation symbolTexture(ItemStack page) { static ResourceLocation symbolTexture(ItemStack page) {
var content = PageItem.content(page); var content = PageItem.content(page);
if (content.kind() == net.dnorberg.rivencraft.writing.PageContent.Kind.UNRESOLVED_SYMBOL) { if (content.kind() == net.dnorberg.rivencraft.writing.PageContent.Kind.UNRESOLVED_SYMBOL) {
return UNRESOLVED_SYMBOL; return UNRESOLVED_SYMBOL;
@@ -1,13 +1,27 @@
package net.dnorberg.rivencraft.client.screen; package net.dnorberg.rivencraft.client.screen;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.WritingDeskMenu; import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
/** Functional storage surface; full Legacy transcription controls are a later Milestone 4 slice. */ /** Functional storage surface; full Legacy transcription controls are a later Milestone 4 slice. */
public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> { public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> {
private static final ResourceLocation PAGE_ATLAS = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/notebook_pages.png");
private int lastCollection = -1;
private int selectedPage;
private int scrollRows;
public WritingDeskScreen(WritingDeskMenu menu, Inventory inventory, Component title) { public WritingDeskScreen(WritingDeskMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title); super(menu, inventory, title);
imageWidth = 398; imageWidth = 398;
@@ -17,6 +31,10 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
inventoryLabelY = 79; inventoryLabelY = 79;
} }
@Override public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
// Legacy leaves the world visible around the desk instead of dimming it.
}
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) { @Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
int x = leftPos; int x = leftPos;
int y = topPos; int y = topPos;
@@ -29,10 +47,15 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
i == menu.activeCollection() ? 0xFF7777B6 : 0xFF999999); i == menu.activeCollection() ? 0xFF7777B6 : 0xFF999999);
slotFrame(graphics, 12, 20 + i * 37); slotFrame(graphics, 12, 20 + i * 37);
} }
slotFrame(graphics, 370, 20);
slotFrame(graphics, 228, 20); slotFrame(graphics, 228, 20);
slotFrame(graphics, 266, 20); slotFrame(graphics, 228, 57);
slotFrame(graphics, 304, 20); slotFrame(graphics, 370, 57);
slotFrame(graphics, 342, 20); graphics.fill(x + 350, y + 20, x + 364, y + 75, 0xFF303035);
graphics.fill(x + 352, y + 22, x + 362, y + 73, 0xFF15151B);
int filled = 51 * menu.inkRemaining() / 16;
if (filled > 0) graphics.fill(x + 352, y + 73 - filled, x + 362, y + 73, 0xFF6666AA);
graphics.renderOutline(x + 350, y + 20, 14, 55, 0xFF484848);
for (int row = 0; row < 3; row++) { for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) slotFrame(graphics, 224 + column * 18, 91 + row * 18); for (int column = 0; column < 9; column++) slotFrame(graphics, 224 + column * 18, 91 + row * 18);
} }
@@ -46,12 +69,91 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
leftPos + x + 16, topPos + y + 16, 0xFF858585); leftPos + x + 16, topPos + y + 16, 0xFF858585);
} }
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
}
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick); super.render(graphics, mouseX, mouseY, partialTick);
renderCollection(graphics);
renderTooltip(graphics, mouseX, mouseY); renderTooltip(graphics, mouseX, mouseY);
if (mouseX >= leftPos + 350 && mouseX < leftPos + 364
&& mouseY >= topPos + 20 && mouseY < topPos + 75) {
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.ink",
menu.inkRemaining(), 16), mouseX, mouseY);
}
}
private List<ItemStack> pages() {
ItemStack collection = menu.getSlot(menu.activeCollection()).getItem();
if (collection.is(ModItems.PAGE.get())) return List.of(collection);
if (collection.is(ModItems.NOTEBOOK.get())) {
return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
}
return List.of();
}
private void renderCollection(GuiGraphics graphics) {
List<ItemStack> pages = pages();
if (lastCollection != menu.activeCollection()) {
lastCollection = menu.activeCollection();
scrollRows = 0;
selectedPage = 0;
}
int last = -1;
for (int i = 0; i < pages.size(); i++) if (!pages.get(i).isEmpty()) last = i;
for (int i = scrollRows * 5; i <= last && i < scrollRows * 5 + 15; i++) {
int column = i % 5;
int row = i / 5 - scrollRows;
ItemStack page = pages.get(i);
int x = leftPos + 48 + column * 31;
int y = topPos + 18 + row * 41;
drawPage(graphics, page, x, y);
if (page.isEmpty()) graphics.fill(x, y, x + 30, y + 40, 0xB8000000);
if (i == selectedPage && !page.isEmpty()) graphics.renderOutline(x, y, 30, 40, 0xFFFFD061);
}
if (selectedPage >= pages.size() || pages.get(selectedPage).isEmpty()) return;
ItemStack selected = pages.get(selectedPage);
graphics.pose().pushPose();
graphics.pose().translate(leftPos + 294, topPos + 12, 0);
graphics.pose().scale(1.35F, 1.35F, 1.0F);
drawPage(graphics, selected, 0, 0);
graphics.pose().popPose();
String title = font.plainSubstrByWidth(selected.getHoverName().getString(), 112);
graphics.fill(leftPos + 251, topPos + 64, leftPos + 365, topPos + 77, 0xFF202020);
graphics.drawString(font, title, leftPos + 254, topPos + 67, 0xFFE0E0E0, false);
}
private void drawPage(GuiGraphics graphics, ItemStack page, int x, int y) {
graphics.blit(PAGE_ATLAS, x, y, 30, 40, 156, 0, 30, 40, 256, 256);
if (page.isEmpty()) return;
switch (PageItem.content(page).kind()) {
case BLANK -> { }
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000);
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page), x + 1, y + 6,
28, 28, 16, 16, 96, 96, 128, 128);
}
} }
@Override public boolean mouseClicked(double mouseX, double mouseY, int button) { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button == 0) {
int x = (int) mouseX - leftPos - 48;
int y = (int) mouseY - topPos - 18;
if (x >= 0 && x < 155 && y >= 0 && y < 123) {
int column = x / 31;
int row = y / 41;
int index = (row + scrollRows) * 5 + column;
List<ItemStack> pages = pages();
if (x % 31 < 30 && y % 41 < 40 && index < pages.size()
&& !pages.get(index).isEmpty()) {
selectedPage = index;
if (minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, 16 + index);
}
}
return true;
}
}
if (button == 0 && mouseX >= leftPos + 29 && mouseX < leftPos + 40) { if (button == 0 && mouseX >= leftPos + 29 && mouseX < leftPos + 40) {
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
int top = topPos + 16 + i * 37; int top = topPos + 16 + i * 37;
@@ -65,4 +167,14 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
} }
return super.mouseClicked(mouseX, mouseY, button); return super.mouseClicked(mouseX, mouseY, button);
} }
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (mouseX >= leftPos + 48 && mouseX < leftPos + 203
&& mouseY >= topPos + 18 && mouseY < topPos + 141) {
int rows = (pages().size() + 4) / 5;
scrollRows = Math.clamp(scrollRows - (int) Math.signum(scrollY), 0, Math.max(0, rows - 3));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
} }
@@ -1,15 +1,21 @@
package net.dnorberg.rivencraft.gametest; package net.dnorberg.rivencraft.gametest;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.menu.WritingDeskMenu; import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.GameTestHolder;
@@ -65,21 +71,139 @@ public final class WritingDeskGameTests {
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos); WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
var player = helper.makeMockServerPlayerInLevel(); var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5); player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
player.getInventory().setItem(9, new ItemStack(ModItems.PAGE.get(), 12)); player.getInventory().setItem(9, new ItemStack(Items.PAPER, 12));
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk); WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertTrue(menu.stillValid(player), "Placed desk must keep its menu valid"); helper.assertTrue(menu.stillValid(player), "Placed desk must keep its menu valid");
helper.assertTrue(menu.clickMenuButton(player, 3), "Active collection control must accept a valid tab"); helper.assertTrue(menu.clickMenuButton(player, 3), "Active collection control must accept a valid tab");
helper.assertValueEqual(desk.activeCollection(), 3, "Selected tab must be stored on the desk"); helper.assertValueEqual(desk.activeCollection(), 3, "Selected tab must be stored on the desk");
menu.quickMoveStack(player, 8); menu.quickMoveStack(player, 9);
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Shift-click must deposit all pages"); helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Shift-click must deposit all pages");
helper.assertTrue(player.getInventory().getItem(9).isEmpty(), "Shift-click must consume player source stack"); helper.assertTrue(player.getInventory().getItem(9).isEmpty(), "Shift-click must consume player source stack");
menu.quickMoveStack(player, WritingDeskBlockEntity.PAPER); menu.quickMoveStack(player, WritingDeskBlockEntity.PAPER);
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(), "Shift-click must empty desk source slot"); helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(), "Shift-click must empty desk source slot");
int total = player.getInventory().items.stream().filter(stack -> stack.is(ModItems.PAGE.get())) int total = player.getInventory().items.stream().filter(stack -> stack.is(Items.PAPER))
.mapToInt(ItemStack::getCount).sum(); .mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(total, 12, "Round-trip must not lose or duplicate pages"); helper.assertValueEqual(total, 12, "Round-trip must not lose or duplicate pages");
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3); helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(menu.stillValid(player), "Removed desk must invalidate menu"); helper.assertFalse(menu.stillValid(player), "Removed desk must invalidate menu");
helper.succeed(); 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);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
ItemStack source = PageItem.createLinkPanelPage();
desk.setItem(0, source.copy());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
"A source page with paper and ink must preview one copy");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 2,
"Previewing must not consume paper");
helper.assertValueEqual(desk.inkRemaining(), 0, "Previewing must not consume ink");
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ClickType.PICKUP, player);
helper.assertTrue(menu.getCarried().is(ModItems.PAGE.get()), "Taking result must give the copied page");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Taking a copy must consume one paper");
helper.assertValueEqual(desk.inkRemaining(), 15, "First copy must refill to 16 and consume one ink");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).isEmpty(), "Refill must consume one vial");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE),
"Refill must return a vanilla glass bottle");
helper.assertTrue(desk.getItem(0).is(ModItems.PAGE.get()), "Source page must remain untouched");
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
"A second copy must remain available from the same ink fill");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
"Two copies must consume exactly two paper");
helper.assertValueEqual(desk.inkRemaining(), 14, "Two copies must spend exactly two of sixteen ink");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.RETURN).getCount(), 1,
"A second copy must not consume another vial");
var saved = desk.saveWithoutMetadata(helper.getLevel().registryAccess());
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
helper.assertValueEqual(restored.inkRemaining(), 14, "Ink meter must persist across saves");
helper.succeed();
}
@GameTest(template = "empty")
public static void shiftClickCopiesOnlyWhenResourcesAndBottleSpaceExist(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
desk.setItem(WritingDeskBlockEntity.RETURN, new ItemStack(Items.GLASS_BOTTLE, 64));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Full bottle return must block refill without consuming supplies");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 2,
"Blocked copy must leave paper untouched");
desk.removeItem(WritingDeskBlockEntity.RETURN, 1);
ItemStack copied = menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT);
helper.assertTrue(copied.is(ModItems.PAGE.get()), "Shift-click must transfer one copy");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Shift-click must consume one paper");
helper.assertValueEqual(desk.inkRemaining(), 15, "Shift-click must consume one ink");
int copies = player.getInventory().items.stream().filter(stack -> stack.is(ModItems.PAGE.get()))
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(copies, 1, "Shift-click must give exactly one page");
helper.succeed();
}
@GameTest(template = "empty")
public static void selectedNotebookPageControlsCopyPreview(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
new NotebookContents(List.of(ItemStack.EMPTY, PageItem.createLinkPanelPage())));
desk.setItem(0, notebook);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty(),
"An empty selected position must not create a copy");
helper.assertTrue(menu.clickMenuButton(player, 17), "Second page selection must be accepted");
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
"Selecting the populated page must show a copy");
helper.succeed();
}
@GameTest(template = "empty")
public static void twoViewersCannotTakeTheSameLastSheet(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var first = helper.makeMockServerPlayerInLevel();
var second = helper.makeMockServerPlayerInLevel();
first.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
second.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu firstMenu = new WritingDeskMenu(1, first.getInventory(), desk);
WritingDeskMenu secondMenu = new WritingDeskMenu(2, second.getInventory(), desk);
firstMenu.broadcastChanges();
secondMenu.broadcastChanges();
helper.assertTrue(firstMenu.quickMoveStack(first, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
"First viewer should take the last available copy");
helper.assertTrue(secondMenu.quickMoveStack(second, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Second viewer must not copy the already-consumed paper");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
"Shared desk must spend paper only once");
helper.succeed();
}
} }
@@ -1,8 +1,13 @@
package net.dnorberg.rivencraft.menu; package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus; import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PageContent;
import java.util.List;
import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container; import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer; import net.minecraft.world.SimpleContainer;
@@ -12,12 +17,18 @@ import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.DataSlot; import net.minecraft.world.inventory.DataSlot;
import net.minecraft.world.inventory.Slot; import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
public final class WritingDeskMenu extends AbstractContainerMenu { public final class WritingDeskMenu extends AbstractContainerMenu {
private static final int PLAYER_START = WritingDeskBlockEntity.SIZE; public static final int COPY_RESULT = WritingDeskBlockEntity.SIZE;
private static final int PLAYER_START = COPY_RESULT + 1;
private final Container desk; private final Container desk;
private final WritingDeskBlockEntity blockEntity; private final WritingDeskBlockEntity blockEntity;
private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide;
private int syncedActiveCollection; private int syncedActiveCollection;
private int syncedInkRemaining;
private int selectedPage;
public WritingDeskMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) { public WritingDeskMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos()) this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
@@ -28,18 +39,25 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
super(ModMenus.WRITING_DESK.get(), id); super(ModMenus.WRITING_DESK.get(), id);
this.blockEntity = blockEntity; this.blockEntity = blockEntity;
this.desk = blockEntity == null ? new SimpleContainer(WritingDeskBlockEntity.SIZE) : blockEntity; this.desk = blockEntity == null ? new SimpleContainer(WritingDeskBlockEntity.SIZE) : blockEntity;
this.clientSide = inventory.player.level().isClientSide;
addDataSlot(new DataSlot() { addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null @Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedActiveCollection : WritingDeskMenu.this.blockEntity.activeCollection(); } ? syncedActiveCollection : WritingDeskMenu.this.blockEntity.activeCollection(); }
@Override public void set(int value) { syncedActiveCollection = value; } @Override public void set(int value) { syncedActiveCollection = value; }
}); });
addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedInkRemaining : WritingDeskMenu.this.blockEntity.inkRemaining(); }
@Override public void set(int value) { syncedInkRemaining = value; }
});
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) { for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) {
addSlot(new DeskSlot(desk, i, 12, 20 + i * 37, 1)); addSlot(new DeskSlot(desk, i, 12, 20 + i * 37, 1));
} }
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.INK, 228, 20, 1)); addSlot(new DeskSlot(desk, WritingDeskBlockEntity.INK, 370, 20, 16));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.SOURCE, 266, 20, 1)); addSlot(new DeskSlot(desk, WritingDeskBlockEntity.RETURN, 370, 57, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.PAPER, 304, 20, 64)); addSlot(new DeskSlot(desk, WritingDeskBlockEntity.PAPER, 228, 20, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.OUTPUT, 342, 20, 64)); addSlot(new DeskSlot(desk, WritingDeskBlockEntity.LEGACY_OUTPUT, -1000, -1000, 64));
addSlot(new CopySlot(result, 0, 228, 57));
for (int row = 0; row < 3; row++) { for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) { for (int column = 0; column < 9; column++) {
addSlot(new Slot(inventory, column + row * 9 + 9, 224 + column * 18, 91 + row * 18)); addSlot(new Slot(inventory, column + row * 9 + 9, 224 + column * 18, 91 + row * 18));
@@ -51,10 +69,17 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
} }
public int activeCollection() { return syncedActiveCollection; } public int activeCollection() { return syncedActiveCollection; }
public int inkRemaining() { return syncedInkRemaining; }
@Override public boolean clickMenuButton(Player player, int id) { @Override public boolean clickMenuButton(Player player, int id) {
if (blockEntity == null || !stillValid(player) || id < 0 || id >= WritingDeskBlockEntity.COLLECTIONS) return false; if (blockEntity == null || !stillValid(player)) return false;
blockEntity.setActiveCollection(id); if (id >= 0 && id < WritingDeskBlockEntity.COLLECTIONS) {
blockEntity.setActiveCollection(id);
selectedPage = 0;
} else if (id >= 16 && id < 16 + NotebookContents.MAX_PAGES) {
selectedPage = id - 16;
} else return false;
refreshOutput();
return true; return true;
} }
@@ -64,11 +89,23 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
@Override public ItemStack quickMoveStack(Player player, int index) { @Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY; if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
if (index == COPY_RESULT) {
refreshOutput();
if (!canCopy()) return ItemStack.EMPTY;
ItemStack copy = selectedSource().copyWithCount(1);
ItemStack movingCopy = copy.copy();
if (!moveItemStackTo(movingCopy, PLAYER_START, slots.size(), true) || !movingCopy.isEmpty()) {
return ItemStack.EMPTY;
}
blockEntity.consumeCopyMaterials();
refreshOutput();
return copy;
}
Slot slot = slots.get(index); Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY; if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack moving = slot.getItem(); ItemStack moving = slot.getItem();
ItemStack original = moving.copy(); ItemStack original = moving.copy();
if (index < PLAYER_START) { if (index < COPY_RESULT) {
if (!moveItemStackTo(moving, PLAYER_START, slots.size(), true)) return ItemStack.EMPTY; if (!moveItemStackTo(moving, PLAYER_START, slots.size(), true)) return ItemStack.EMPTY;
} else if (!moveIntoDesk(moving)) { } else if (!moveIntoDesk(moving)) {
return ItemStack.EMPTY; return ItemStack.EMPTY;
@@ -81,14 +118,65 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
private boolean moveIntoDesk(ItemStack moving) { private boolean moveIntoDesk(ItemStack moving) {
if (moving.is(ModItems.INK_VIAL.get())) return moveItemStackTo(moving, 4, 5, false); if (moving.is(ModItems.INK_VIAL.get())) return moveItemStackTo(moving, 4, 5, false);
if (moving.is(ModItems.NOTEBOOK.get())) return moveItemStackTo(moving, 0, 4, false); if (moving.is(ModItems.NOTEBOOK.get())) return moveItemStackTo(moving, 0, 4, false);
if (moving.is(ModItems.PAGE.get())) { if (moving.is(Items.PAPER) || moving.is(ModItems.PAGE.get())
return moveItemStackTo(moving, 6, 7, false) && PageItem.content(moving).kind() == PageContent.Kind.BLANK)
|| moveItemStackTo(moving, 0, 4, false) return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER,
|| moveItemStackTo(moving, 5, 6, false); WritingDeskBlockEntity.PAPER + 1, false);
} if (moving.is(ModItems.PAGE.get())) return moveItemStackTo(moving, 0, 4, false);
return false; return false;
} }
private ItemStack selectedSource() {
ItemStack collection = desk.getItem(blockEntity == null ? syncedActiveCollection : blockEntity.activeCollection());
if (collection.is(ModItems.PAGE.get())) return selectedPage == 0 ? collection : ItemStack.EMPTY;
if (collection.is(ModItems.NOTEBOOK.get())) {
List<ItemStack> pages = collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
return selectedPage < pages.size() ? pages.get(selectedPage) : ItemStack.EMPTY;
}
return ItemStack.EMPTY;
}
private boolean canCopy() {
ItemStack source = selectedSource();
return blockEntity != null && source.is(ModItems.PAGE.get())
&& PageItem.content(source).kind() != PageContent.Kind.BLANK
&& PageItem.isUsableWriting(source) && blockEntity.canConsumeCopyMaterials();
}
private void refreshOutput() {
if (clientSide) return;
ItemStack preview = canCopy() ? selectedSource().copyWithCount(1) : ItemStack.EMPTY;
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
}
@Override public void broadcastChanges() {
refreshOutput();
super.broadcastChanges();
}
@Override public void clicked(int slotId, int button, net.minecraft.world.inventory.ClickType clickType,
Player player) {
if (!stillValid(player)) {
player.closeContainer();
return;
}
refreshOutput();
super.clicked(slotId, button, clickType, player);
}
private final class CopySlot extends Slot {
private CopySlot(Container container, int index, int x, int y) { super(container, index, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) {
return clientSide ? hasItem() : WritingDeskMenu.this.stillValid(player) && canCopy();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && blockEntity.consumeCopyMaterials()) refreshOutput();
super.onTake(player, stack);
}
}
private static final class DeskSlot extends Slot { private static final class DeskSlot extends Slot {
private final int limit; private final int limit;
private DeskSlot(Container container, int index, int x, int y, int limit) { private DeskSlot(Container container, int index, int x, int y, int limit) {
@@ -18,6 +18,7 @@
"item.rivencraft.ink_vial": "Ink Vial", "item.rivencraft.ink_vial": "Ink Vial",
"block.rivencraft.writing_desk": "Writing Desk", "block.rivencraft.writing_desk": "Writing Desk",
"container.rivencraft.writing_desk": "Writing Desk", "container.rivencraft.writing_desk": "Writing Desk",
"screen.rivencraft.writing_desk.ink": "Ink: %s/%s",
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s", "item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
"item.rivencraft.ink_vial.usage": "Use with a blank Page in the other hand to prepare a Link Panel.", "item.rivencraft.ink_vial.usage": "Use with a blank Page in the other hand to prepare a Link Panel.",
"screen.rivencraft.notebook.search": "Search pages", "screen.rivencraft.notebook.search": "Search pages",