Compare commits

...
7 Commits
Author SHA1 Message Date
dnorberg 02901c9cbc Remove notebook rename flow and add Pages creative tab
Build / build (push) Canceled after 0s
2026-09-24 04:46:51 -04:00
dnorberg d6fd32994f Add writing ink and baseline link panel preparation
Build / build (push) Canceled after 0s
2026-09-24 04:38:05 -04:00
dnorberg 143da0a940 Keep notebook rename keys out of inventory shortcuts
Build / build (push) Canceled after 0s
2026-09-24 04:27:51 -04:00
dnorberg dc5157f973 Fix notebook inventory clicks and ALL button
Build / build (push) Canceled after 0s
2026-09-24 04:23:28 -04:00
dnorberg 956b91c462 Match Legacy notebook page surface
Build / build (push) Canceled after 0s
2026-09-24 04:17:19 -04:00
dnorberg c980366278 Refine notebook screen layout
Build / build (push) Canceled after 0s
2026-09-23 23:28:04 -04:00
dnorberg 616e3d2af5 Add notebook page storage and menu (#19)
Build / build (push) Canceled after 0s
2026-09-23 23:21:09 -04:00
28 changed files with 1098 additions and 5 deletions

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ It is not an official release of Mystcraft.
Mystcraft Legacy source: https://github.com/Mystcraft/Mystcraft-Legacy
Mystcraft Legacy copyright: XCompWiz and contributors
Mystcraft Legacy license: GNU Lesser General Public License version 3
The animated ink-vial item texture in this project is copied from Mystcraft
Legacy's assets/mystcraft/textures/items/ink_vial.png under that license.
The initial build layout is derived from the official NeoForge 1.21.1
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
+7 -2
View File
@@ -13,8 +13,13 @@ have not been ported yet.
Milestone 4 now includes stackable blank Pages plus server-created symbol,
unresolved-symbol, and baseline link-panel states. Page visuals follow the
legacy runtime-composition rules; written-page creation will become available
through the Writing Desk work that follows.
legacy runtime-composition rules. Craft an Ink Vial from two black dye, a glass
bottle, and a water bucket; the bucket is returned. Hold a blank Page in one
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.
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`.
Symbol-page transcription will become available through the Writing Desk.
## Linking Book
+17
View File
@@ -64,3 +64,20 @@ identifier. Built-in pages reproduce Mystcraft Legacy's four-word poem
composition from its page background and symbol-component sheet. Data-pack
symbols without a bundled resource-pack model use the legacy question-mark
fallback while keeping their real identifier and display metadata.
## Ink and baseline link panels
An Ink Vial is crafted from two black dye, a glass bottle, and a water bucket.
The bucket remains after crafting. Each vial holds 16 ink units, stored in a
persistent, network-synchronized component; only vials with identical remaining
ink stack together. When depleted, a vial returns a glass bottle that can be
used to craft another. The animated item texture comes from Mystcraft Legacy.
The server config sets ink costs independently for symbol copying (default one
unit) and baseline link-panel preparation (default two units). To prepare a
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
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
copying will be implemented by the Writing Desk. Link-property modifiers are
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.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.3.2.3
mod_version=0.3.4.1
immersive_portals_version=6.0.7
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
@@ -4,6 +4,7 @@ import com.mojang.logging.LogUtils;
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.registry.ModSounds;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
@@ -41,6 +42,7 @@ public final class Rivencraft {
ModBlocks.register(modEventBus);
ModBlockEntities.register(modEventBus);
ModItems.register(modEventBus);
ModMenus.register(modEventBus);
ModSounds.register(modEventBus);
ModCreativeTabs.register(modEventBus);
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
@@ -12,6 +12,9 @@ import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.dnorberg.rivencraft.client.screen.NotebookScreen;
import net.dnorberg.rivencraft.registry.ModMenus;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class ClientEvents {
@@ -23,6 +26,11 @@ public final class ClientEvents {
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
}
@SubscribeEvent
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new);
}
@SubscribeEvent
public static void clientSetup(FMLClientSetupEvent event) {
event.enqueueWork(() -> ItemProperties.register(
@@ -0,0 +1,288 @@
package net.dnorberg.rivencraft.client.screen;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.item.ItemStack;
/** A page surface modelled on Legacy's folder GUI, backed by the Notebook menu. */
public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu> {
private static final int SURFACE_TOP = 20;
private static final int SURFACE_BOTTOM = 132;
private static final int SURFACE_RIGHT = 156;
private static final int PAGE_WIDTH = 30;
private static final int PAGE_HEIGHT = 40;
private static final int PAGE_STEP_X = 31;
private static final int PAGE_STEP_Y = 41;
private static final int PAGE_COLUMNS = 5;
private EditBox textInput;
private boolean draggingScrollbar;
private boolean surfaceClickPendingRelease;
private int selectedSlot = -1;
private int scroll;
public NotebookScreen(NotebookMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
imageWidth = 176;
imageHeight = 213;
inventoryLabelY = 123;
}
@Override
protected void init() {
super.init();
Button sortButton = Button.builder(Component.literal("AZ"), button -> {})
.bounds(leftPos, topPos, 18, 18).build();
Button allButton = Button.builder(Component.literal("ALL"), button -> {})
.bounds(leftPos + 20, topPos, 26, 18).build();
sortButton.active = false;
allButton.active = false;
addRenderableWidget(sortButton);
addRenderableWidget(allButton);
textInput = new EditBox(font, leftPos + 48, topPos, 128, 18,
Component.translatable("screen.rivencraft.notebook.search"));
textInput.setMaxLength(32);
textInput.setHint(Component.translatable("screen.rivencraft.notebook.search"));
textInput.setResponder(value -> scroll = 0);
addRenderableWidget(textInput);
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
List<Integer> pages = visiblePages();
scroll = Math.min(scroll, maxScroll(pages.size()));
graphics.enableScissor(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
topPos + SURFACE_BOTTOM);
for (int position = 0; position < pages.size(); position++) {
int x = leftPos + (position % PAGE_COLUMNS) * PAGE_STEP_X;
int y = topPos + SURFACE_TOP + (position / PAGE_COLUMNS) * PAGE_STEP_Y - scroll;
if (y + PAGE_HEIGHT > topPos + SURFACE_TOP && y < topPos + SURFACE_BOTTOM) {
drawPage(graphics, pages.get(position), x, y);
}
}
graphics.disableScissor();
drawScrollbar(graphics, pages.size());
int hovered = pageAt(mouseX, mouseY, pages);
if (!menu.getCarried().isEmpty() && inSurface(mouseX, mouseY)) {
graphics.renderItem(menu.getCarried(), mouseX - 8, mouseY - 8);
} else if (hovered >= 0) {
ItemStack page = menu.getSlot(hovered).getItem();
if (!page.isEmpty() && matchesSearch(page)) graphics.renderTooltip(font, page, mouseX, mouseY);
} else {
renderTooltip(graphics, mouseX, mouseY);
}
}
@Override
protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
graphics.fill(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
topPos + SURFACE_BOTTOM, 0xB0000000);
drawBeveledPanel(graphics, leftPos, topPos + 132, imageWidth, 81);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 134 + row * 18);
}
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 192);
}
@Override
protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (inScrollbar(mouseX, mouseY) && button == 0) {
draggingScrollbar = true;
setScrollFromMouse(mouseY, visiblePages().size());
return true;
}
int index = pageAt(mouseX, mouseY, visiblePages());
if (index >= 0 && (button == 0 || button == 1)) {
ItemStack page = menu.getSlot(index).getItem();
if (!page.isEmpty() && !matchesSearch(page)) return true;
setFocused(null);
textInput.setFocused(false);
selectedSlot = index;
slotClicked(menu.getSlot(index), index, button, ClickType.PICKUP);
surfaceClickPendingRelease = true;
return true;
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) {
if (draggingScrollbar && button == 0) {
setScrollFromMouse(mouseY, visiblePages().size());
return true;
}
return super.mouseDragged(mouseX, mouseY, button, dragX, dragY);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
if (draggingScrollbar || surfaceClickPendingRelease) {
draggingScrollbar = false;
surfaceClickPendingRelease = false;
return true;
}
draggingScrollbar = false;
return super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (inSurface(mouseX, mouseY) || inScrollbar(mouseX, mouseY)) {
scroll = Math.clamp(scroll - (int)Math.signum(scrollY) * 20,
0, maxScroll(visiblePages().size()));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (textInput.isFocused()) {
if (keyCode == 256) {
setFocused(null);
textInput.setFocused(false);
} else {
textInput.keyPressed(keyCode, scanCode, modifiers);
}
// EditBox does not consume ordinary letter key presses; the matching charTyped
// event handles text input. Do not pass them to the container's inventory keys.
return true;
}
if (!textInput.isFocused() && selectedSlot >= 0 && (keyCode == 263 || keyCode == 262)) {
moveSelected(keyCode == 262);
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
private List<Integer> visiblePages() {
List<Integer> pages = new ArrayList<>();
int firstEmpty = -1;
for (int index = 0; index < NotebookMenu.NOTEBOOK_SLOTS; index++) {
ItemStack stack = menu.getSlot(index).getItem();
if (stack.isEmpty()) {
if (firstEmpty < 0) firstEmpty = index;
} else {
pages.add(index);
}
}
if (firstEmpty >= 0 && menu.getCarried().is(ModItems.PAGE.get())) {
pages.add(firstEmpty);
}
return pages;
}
private boolean matchesSearch(ItemStack page) {
return textInput.getValue().isBlank()
|| page.getHoverName().getString().toLowerCase(Locale.ROOT)
.contains(textInput.getValue().toLowerCase(Locale.ROOT));
}
private int pageAt(double mouseX, double mouseY, List<Integer> pages) {
if (!inSurface(mouseX, mouseY)) return -1;
int localX = (int)mouseX - leftPos;
int localY = (int)mouseY - topPos - SURFACE_TOP + scroll;
int column = localX / PAGE_STEP_X;
int position = (localY / PAGE_STEP_Y) * PAGE_COLUMNS + column;
return column < PAGE_COLUMNS && localX % PAGE_STEP_X < PAGE_WIDTH
&& localY % PAGE_STEP_Y < PAGE_HEIGHT && position < pages.size()
? pages.get(position) : -1;
}
private boolean inSurface(double mouseX, double mouseY) {
return mouseX >= leftPos && mouseX < leftPos + SURFACE_RIGHT
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private boolean inScrollbar(double mouseX, double mouseY) {
return mouseX >= leftPos + SURFACE_RIGHT && mouseX < leftPos + imageWidth
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private int maxScroll(int pageCount) {
int rows = (pageCount + PAGE_COLUMNS - 1) / PAGE_COLUMNS;
return Math.max(0, rows * PAGE_STEP_Y - (SURFACE_BOTTOM - SURFACE_TOP));
}
private void setScrollFromMouse(double mouseY, int pageCount) {
int maximum = maxScroll(pageCount);
double fraction = (mouseY - topPos - SURFACE_TOP - 10.0)
/ (SURFACE_BOTTOM - SURFACE_TOP - 20.0);
scroll = Math.clamp((int)(Math.clamp(fraction, 0.0, 1.0) * maximum), 0, maximum);
}
private void drawScrollbar(GuiGraphics graphics, int pageCount) {
int x = leftPos + SURFACE_RIGHT;
int y = topPos + SURFACE_TOP;
int height = SURFACE_BOTTOM - SURFACE_TOP;
graphics.fill(x, y, x + 20, y + height, 0xFF858585);
graphics.renderOutline(x, y, 20, height, 0xFF262626);
for (int line = y + 5; line < y + height - 4; line += 5) {
graphics.fill(x + 4, line, x + 16, line + 1, 0xFFB7B7B7);
}
int maximum = maxScroll(pageCount);
int thumbY = y + 4 + (maximum == 0 ? 0 : scroll * (height - 23) / maximum);
graphics.fill(x + 4, thumbY, x + 16, thumbY + 15, 0xFFC9C9C9);
graphics.renderOutline(x + 4, thumbY, 12, 15, 0xFF414141);
}
private void drawPage(GuiGraphics graphics, int index, int x, int y) {
ItemStack page = menu.getSlot(index).getItem();
boolean visible = !page.isEmpty() && matchesSearch(page);
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT,
visible ? 0xFFE5D4A7 : 0x704B4945);
graphics.fill(x + 2, y + 2, x + PAGE_WIDTH - 2, y + PAGE_HEIGHT - 2,
visible ? 0xFFF4E5C3 : 0x603B3934);
graphics.renderOutline(x, y, PAGE_WIDTH, PAGE_HEIGHT,
index == selectedSlot ? 0xFFFFD061 : 0xFF564A34);
if (visible) {
graphics.pose().pushPose();
graphics.pose().translate(x + 3, y + 8, 0);
graphics.pose().scale(1.5F, 1.5F, 1.0F);
graphics.renderItem(page, 0, 0);
graphics.pose().popPose();
}
}
private void moveSelected(boolean next) {
int target = selectedSlot + (next ? 1 : -1);
if (selectedSlot < 0 || target < 0 || target >= NotebookMenu.NOTEBOOK_SLOTS
|| menu.getSlot(selectedSlot).getItem().isEmpty()) return;
int buttonId = (next ? NotebookMenu.MOVE_NEXT_BASE : NotebookMenu.MOVE_PREVIOUS_BASE) + selectedSlot;
if (menu.clickMenuButton(minecraft.player, buttonId)) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
selectedSlot = target;
}
}
private static void drawBeveledPanel(GuiGraphics graphics, int x, int y, int width, int height) {
graphics.fill(x, y, x + width, y + height, 0xFFC6C6C6);
graphics.fill(x, y, x + width, y + 2, 0xFFFFFFFF);
graphics.fill(x, y, x + 2, y + height, 0xFFFFFFFF);
graphics.fill(x, y + height - 2, x + width, y + height, 0xFF555555);
graphics.fill(x + width - 2, y, x + width, y + height, 0xFF555555);
}
private void drawSlot(GuiGraphics graphics, int x, int y) {
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFF8B8B8B);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 1, 0xFF373737);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 1, topPos + y + 18, 0xFF373737);
graphics.fill(leftPos + x + 17, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
graphics.fill(leftPos + x, topPos + y + 17, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
}
}
@@ -15,6 +15,8 @@ public final class RivencraftConfig {
public static final ModConfigSpec SPEC;
private static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
static {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
@@ -25,6 +27,12 @@ public final class RivencraftConfig {
)
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
builder.pop();
builder.push("writing");
SYMBOL_INK_COST = builder.comment("Ink units consumed when the Writing Desk copies one symbol page.")
.defineInRange("symbol_ink_cost", 1, 1, 16);
LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline link-panel page.")
.defineInRange("link_panel_ink_cost", 2, 1, 16);
builder.pop();
SPEC = builder.build();
}
@@ -34,4 +42,12 @@ public final class RivencraftConfig {
public static ReturnLinkMode returnLinkMode() {
return RETURN_LINK_MODE.get();
}
public static int symbolInkCost() {
return SYMBOL_INK_COST.get();
}
public static int linkPanelInkCost() {
return LINK_PANEL_INK_COST.get();
}
}
@@ -9,14 +9,22 @@ import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.inventory.ClickType;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@@ -118,6 +126,172 @@ public final class WritingDataGameTests {
}
}
@GameTest(template = "empty")
public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) {
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
net.minecraft.network.chat.Component.literal("Foundation"));
ItemStack unknown = PageItem.createRecoveredPage(reference("example:removed_symbol"));
NotebookContents contents = new NotebookContents(List.of(first, ItemStack.EMPTY, unknown));
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), contents);
CompoundTag serialized = (CompoundTag)notebook.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), serialized);
NotebookContents restoredContents = restored.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
helper.assertTrue(restoredContents != null, "Notebook contents must survive ItemStack persistence");
helper.assertValueEqual(restoredContents.pages().size(), 3,
"Notebook persistence must retain ordered positions");
helper.assertTrue(restoredContents.pages().get(1).isEmpty(),
"Notebook persistence must retain empty positions between pages");
helper.assertValueEqual(restoredContents.pages().get(0).getHoverName().getString(), "Foundation",
"Renamed contained pages must retain their names");
helper.assertValueEqual(PageItem.content(restoredContents.pages().get(2)).kind(),
PageContent.Kind.UNRESOLVED_SYMBOL,
"Unknown symbol pages must remain present and removable");
helper.succeed();
}
@GameTest(template = "empty")
public static void notebookMenuValidatesInsertionAndPersistsReorder(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
player.setItemInHand(InteractionHand.MAIN_HAND, notebook);
NotebookMenu menu = new NotebookMenu(7, player.getInventory(), InteractionHand.MAIN_HAND);
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
net.minecraft.network.chat.Component.literal("Terrain Base"));
ItemStack second = PageItem.createRecoveredPage(reference("example:removed_symbol"));
helper.assertFalse(menu.getSlot(0).mayPlace(new ItemStack(Items.DIRT)),
"Notebook slots must reject non-Page items");
menu.getSlot(0).set(first);
menu.getSlot(1).set(second);
helper.assertTrue(menu.clickMenuButton(player, NotebookMenu.MOVE_NEXT_BASE),
"The server menu must accept a valid adjacent reorder");
NotebookContents saved = notebook.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
helper.assertTrue(saved != null, "Menu changes must persist immediately to the Notebook item");
helper.assertValueEqual(saved.pages().get(1).getHoverName().getString(), "Terrain Base",
"Reordering must preserve existing Page item data");
helper.assertValueEqual(PageItem.content(saved.pages().get(0)).kind(),
PageContent.Kind.UNRESOLVED_SYMBOL,
"Reordering must preserve unresolved pages");
helper.succeed();
}
@GameTest(template = "empty")
public static void notebookMenuSurvivesInventoryStackReplacementAndClicks(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(ModItems.NOTEBOOK.get()));
NotebookMenu menu = new NotebookMenu(8, player.getInventory(), InteractionHand.MAIN_HAND);
// Slot synchronization may replace the ItemStack object while the Notebook remains held.
player.setItemInHand(InteractionHand.MAIN_HAND, player.getMainHandItem().copy());
helper.assertTrue(menu.stillValid(player),
"Replacing the held stack object must not close an open Notebook menu");
menu.getSlot(0).set(PageItem.createSymbolPage(reference("rivencraft:terrain/standard")));
helper.assertTrue(player.getMainHandItem().has(ModDataComponents.NOTEBOOK_CONTENTS.get()),
"Page edits must persist to the current held Notebook stack");
player.getInventory().setItem(9, new ItemStack(Items.DIRT));
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS, 0, ClickType.PICKUP, player);
helper.assertTrue(menu.getCarried().is(Items.DIRT),
"Clicking a player inventory item must pick it up without closing the Notebook");
helper.assertTrue(menu.stillValid(player),
"Normal inventory clicks must leave the Notebook menu valid");
helper.succeed();
}
@GameTest(template = "empty")
public static void inkVialsPersistAndOnlyStackWithMatchingContents(GameTestHelper helper) {
ItemStack vial = new ItemStack(ModItems.INK_VIAL.get());
vial.set(ModDataComponents.INK_REMAINING.get(), 7);
CompoundTag serialized = (CompoundTag) vial.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), serialized);
helper.assertValueEqual(InkVialItem.remaining(restored), 7,
"Remaining ink must survive ItemStack persistence");
helper.assertTrue(ItemStack.isSameItemSameComponents(vial, restored),
"Vials with equal ink contents must be eligible to stack");
helper.assertFalse(ItemStack.isSameItemSameComponents(vial, new ItemStack(ModItems.INK_VIAL.get())),
"Vials with different ink contents must not stack");
helper.succeed();
}
@GameTest(template = "empty")
public static void linkPanelPreparationConsumesOnePageAndConfiguredInkOnlyOnSuccess(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack pages = new ItemStack(ModItems.PAGE.get(), 2);
ItemStack vial = new ItemStack(ModItems.INK_VIAL.get());
player.setItemInHand(InteractionHand.MAIN_HAND, vial);
player.setItemInHand(InteractionHand.OFF_HAND, pages);
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"A blank Page and sufficient ink must prepare one link panel");
helper.assertValueEqual(pages.getCount(), 1, "Preparation must consume exactly one blank Page");
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()),
InkVialItem.CAPACITY - WritingInkService.linkPanelCost(),
"Preparation must consume exactly the server-configured ink cost");
long panels = player.getInventory().items.stream()
.filter(stack -> stack.is(ModItems.PAGE.get())
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL)
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(panels, 1L, "Preparation must output exactly one link panel");
ItemStack written = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
player.setItemInHand(InteractionHand.OFF_HAND, written);
int inkBefore = InkVialItem.remaining(player.getMainHandItem());
helper.assertFalse(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"A written Page must not be overwritten as a link panel");
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()), inkBefore,
"Rejected writing must not consume ink");
helper.assertValueEqual(PageItem.content(written).kind(), PageContent.Kind.SYMBOL,
"Rejected writing must not modify the input Page");
ItemStack lowInk = new ItemStack(ModItems.INK_VIAL.get());
lowInk.set(ModDataComponents.INK_REMAINING.get(), WritingInkService.linkPanelCost() - 1);
player.setItemInHand(InteractionHand.MAIN_HAND, lowInk);
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(ModItems.PAGE.get()));
helper.assertFalse(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"Insufficient ink must reject link-panel preparation");
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()),
WritingInkService.linkPanelCost() - 1,
"Insufficient ink must remain untouched");
helper.assertValueEqual(PageItem.content(player.getOffhandItem()).kind(), PageContent.Kind.BLANK,
"Insufficient ink must leave the Page blank");
lowInk.set(ModDataComponents.INK_REMAINING.get(), WritingInkService.linkPanelCost());
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"Exact remaining ink must be usable");
helper.assertTrue(player.getMainHandItem().is(Items.GLASS_BOTTLE),
"A depleted Ink Vial must return a reusable glass bottle");
helper.assertValueEqual(PageItem.content(player.getOffhandItem()).kind(),
PageContent.Kind.LINK_PANEL,
"A single blank Page must be replaced by one Link Panel Page");
helper.succeed();
}
@GameTest(template = "empty")
public static void stackedInkVialsSplitWithoutChangingTheOtherVials(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(ModItems.INK_VIAL.get(), 2));
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(ModItems.PAGE.get()));
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"A stack of full vials must support preparing one panel");
helper.assertValueEqual(player.getMainHandItem().getCount(), 1,
"Using one vial must leave the other full vial in hand");
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()), InkVialItem.CAPACITY,
"The unconsumed vial must remain full");
long partialVials = player.getInventory().items.stream()
.filter(stack -> stack.is(ModItems.INK_VIAL.get())
&& InkVialItem.remaining(stack) == InkVialItem.CAPACITY - WritingInkService.linkPanelCost())
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(partialVials, 1L,
"The used vial must reappear with only its own ink reduced");
helper.succeed();
}
private static SymbolReference reference(String id) {
return new SymbolReference(ResourceLocation.parse(id));
}
@@ -0,0 +1,47 @@
package net.dnorberg.rivencraft.item;
import java.util.List;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
/** A stackable vial; the ink component is per vial, not shared across a stack. */
public final class InkVialItem extends Item {
public static final int CAPACITY = 16;
public InkVialItem(Properties properties) {
super(properties);
}
public static int remaining(ItemStack vial) {
return vial.getOrDefault(ModDataComponents.INK_REMAINING.get(), CAPACITY);
}
@Override
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
if (!WritingInkService.canPrepareLinkPanel(player, hand)) {
return InteractionResultHolder.pass(player.getItemInHand(hand));
}
if (!level.isClientSide) {
WritingInkService.prepareLinkPanel(player, hand);
}
return InteractionResultHolder.sidedSuccess(player.getItemInHand(hand), level.isClientSide);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context,
List<Component> tooltip, TooltipFlag flag) {
tooltip.add(Component.translatable("item.rivencraft.ink_vial.remaining",
remaining(stack), CAPACITY).withStyle(ChatFormatting.GRAY));
tooltip.add(Component.translatable("item.rivencraft.ink_vial.usage")
.withStyle(ChatFormatting.GRAY));
}
}
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.item;
import java.util.List;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
public final class NotebookItem extends Item {
public NotebookItem(Properties properties) {
super(properties);
}
@Override
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
ItemStack notebook = player.getItemInHand(hand);
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer) {
serverPlayer.openMenu(new SimpleMenuProvider(
(containerId, inventory, ignored) -> new NotebookMenu(containerId, inventory, hand),
notebook.getHoverName()),
buffer -> buffer.writeBoolean(hand == InteractionHand.OFF_HAND));
}
return InteractionResultHolder.sidedSuccess(notebook, level.isClientSide);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context,
List<Component> tooltip, TooltipFlag flag) {
NotebookContents contents = stack.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
tooltip.add(Component.translatable("item.rivencraft.notebook.pages",
contents.pageCount(), NotebookContents.MAX_PAGES).withStyle(ChatFormatting.GRAY));
if (contents.unresolvedCount() > 0) {
tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved",
contents.unresolvedCount()).withStyle(ChatFormatting.RED));
}
}
}
@@ -55,6 +55,10 @@ public final class PageItem extends Item {
@Override
public Component getName(ItemStack stack) {
Component customName = stack.get(DataComponents.CUSTOM_NAME);
if (customName != null) {
return customName;
}
PageContent content = content(stack);
return switch (content.kind()) {
case BLANK -> Component.translatable("item.rivencraft.page.blank");
@@ -0,0 +1,194 @@
package net.dnorberg.rivencraft.menu;
import java.util.List;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.NonNullList;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
public final class NotebookMenu extends AbstractContainerMenu {
public static final int NOTEBOOK_SLOTS = NotebookContents.MAX_PAGES;
public static final int MOVE_PREVIOUS_BASE = 0;
public static final int MOVE_NEXT_BASE = NOTEBOOK_SLOTS;
private static final int PLAYER_INVENTORY_START = NOTEBOOK_SLOTS;
private final Inventory playerInventory;
private final InteractionHand hand;
private final int openedHotbarSlot;
private final NotebookContainer contents;
public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) {
this(containerId, inventory, data.readBoolean() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND);
}
public NotebookMenu(int containerId, Inventory inventory, InteractionHand hand) {
super(ModMenus.NOTEBOOK.get(), containerId);
this.playerInventory = inventory;
this.hand = hand;
this.openedHotbarSlot = inventory.selected;
this.contents = new NotebookContainer(inventory, hand);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
// The client draws these pages on a filtered, scrollable surface instead.
addSlot(new PageSlot(contents, column + row * 9, -1000, -1000));
}
}
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
int inventoryIndex = column + row * 9 + 9;
addSlot(playerSlot(inventoryIndex, 8 + column * 18, 135 + row * 18));
}
}
for (int column = 0; column < 9; column++) {
addSlot(playerSlot(column, 8 + column * 18, 193));
}
}
private Slot playerSlot(int inventoryIndex, int x, int y) {
boolean locked = hand == InteractionHand.MAIN_HAND && inventoryIndex == openedHotbarSlot;
return locked ? new LockedSlot(playerInventory, inventoryIndex, x, y)
: new Slot(playerInventory, inventoryIndex, x, y);
}
@Override
public boolean stillValid(Player player) {
return player.isAlive()
&& (hand == InteractionHand.OFF_HAND || player.getInventory().selected == openedHotbarSlot)
&& player.getItemInHand(hand).is(ModItems.NOTEBOOK.get());
}
@Override
public ItemStack quickMoveStack(Player player, int index) {
Slot slot = slots.get(index);
if (!slot.hasItem()) {
return ItemStack.EMPTY;
}
ItemStack source = slot.getItem();
ItemStack original = source.copy();
if (index < PLAYER_INVENTORY_START) {
if (!moveItemStackTo(source, PLAYER_INVENTORY_START, slots.size(), true)) {
return ItemStack.EMPTY;
}
} else {
if (!source.is(ModItems.PAGE.get())
|| !moveItemStackTo(source, 0, PLAYER_INVENTORY_START, false)) {
return ItemStack.EMPTY;
}
}
if (source.isEmpty()) {
slot.setByPlayer(ItemStack.EMPTY);
} else {
slot.setChanged();
}
return original;
}
@Override
public boolean clickMenuButton(Player player, int id) {
int source;
int target;
if (id >= MOVE_PREVIOUS_BASE && id < MOVE_NEXT_BASE) {
source = id;
target = source - 1;
} else if (id >= MOVE_NEXT_BASE && id < MOVE_NEXT_BASE + NOTEBOOK_SLOTS) {
source = id - MOVE_NEXT_BASE;
target = source + 1;
} else {
return false;
}
if (source < 0 || source >= NOTEBOOK_SLOTS || target < 0 || target >= NOTEBOOK_SLOTS
|| contents.getItem(source).isEmpty()) {
return false;
}
contents.swap(source, target);
broadcastChanges();
return true;
}
@Override
public void clicked(int slotId, int button, ClickType clickType, Player player) {
if (!stillValid(player)) {
player.closeContainer();
return;
}
if (clickType == ClickType.SWAP
&& (button == openedHotbarSlot && hand == InteractionHand.MAIN_HAND
|| button == 40 && hand == InteractionHand.OFF_HAND)) {
return;
}
super.clicked(slotId, button, clickType, player);
}
private static final class PageSlot extends Slot {
private PageSlot(Container container, int slot, int x, int y) {
super(container, slot, x, y);
}
@Override public boolean mayPlace(ItemStack stack) { return stack.is(ModItems.PAGE.get()); }
@Override public int getMaxStackSize() { return 1; }
}
private static final class LockedSlot extends Slot {
private LockedSlot(Container container, int slot, int x, int y) {
super(container, slot, x, y);
}
@Override public boolean mayPickup(Player player) { return false; }
@Override public boolean mayPlace(ItemStack stack) { return false; }
}
private static final class NotebookContainer extends SimpleContainer {
private final Inventory inventory;
private final InteractionHand hand;
private boolean loading;
private NotebookContainer(Inventory inventory, InteractionHand hand) {
super(NOTEBOOK_SLOTS);
this.inventory = inventory;
this.hand = hand;
ItemStack notebook = inventory.player.getItemInHand(hand);
loading = true;
List<ItemStack> saved = notebook.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
for (int index = 0; index < saved.size(); index++) {
super.setItem(index, saved.get(index).copy());
}
loading = false;
}
private void swap(int first, int second) {
loading = true;
ItemStack held = getItem(first);
setItem(first, getItem(second));
setItem(second, held);
loading = false;
setChanged();
}
@Override
public void setChanged() {
super.setChanged();
ItemStack notebook = inventory.player.getItemInHand(hand);
if (loading || !notebook.is(ModItems.NOTEBOOK.get())) {
return;
}
NonNullList<ItemStack> saved = NonNullList.withSize(NOTEBOOK_SLOTS, ItemStack.EMPTY);
for (int index = 0; index < NOTEBOOK_SLOTS; index++) {
ItemStack page = getItem(index);
saved.set(index, page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1));
}
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(saved));
}
}
}
@@ -48,7 +48,7 @@ public final class ModNetworking {
}
public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("9");
PayloadRegistrar registrar = event.registrar("10");
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
ModNetworking::requestDescriptiveBookScreen);
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.CreativeModeTab;
@@ -19,7 +20,18 @@ public final class ModCreativeTabs {
.displayItems((parameters, output) -> {
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
output.accept(ModItems.LINKING_BOOK.get());
output.accept(ModItems.NOTEBOOK.get());
output.accept(ModItems.INK_VIAL.get());
})
.build());
public static final DeferredHolder<CreativeModeTab, CreativeModeTab> PAGES =
CREATIVE_TABS.register("rivencraft_pages", () -> CreativeModeTab.builder()
.title(Component.translatable("itemGroup.rivencraft.pages"))
.icon(PageItem::createLinkPanelPage)
.displayItems((parameters, output) -> {
output.accept(ModItems.PAGE.get());
output.accept(PageItem.createLinkPanelPage());
})
.build());
@@ -4,7 +4,9 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import com.mojang.serialization.Codec;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceLocation;
@@ -53,6 +55,18 @@ public final class ModDataComponents {
.networkSynchronized(SymbolDisplayMetadata.STREAM_CODEC)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<NotebookContents>> NOTEBOOK_CONTENTS =
DATA_COMPONENTS.register("notebook_contents", () -> DataComponentType.<NotebookContents>builder()
.persistent(NotebookContents.CODEC)
.networkSynchronized(NotebookContents.STREAM_CODEC)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<Integer>> INK_REMAINING =
DATA_COMPONENTS.register("ink_remaining", () -> DataComponentType.<Integer>builder()
.persistent(Codec.intRange(0, 16))
.networkSynchronized(ByteBufCodecs.VAR_INT)
.cacheEncoding()
.build());
private ModDataComponents() {
}
@@ -4,6 +4,8 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.NotebookItem;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.minecraft.world.item.Item;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredItem;
@@ -27,6 +29,16 @@ public final class ModItems {
PageItem::new,
new Item.Properties().stacksTo(64)
);
public static final DeferredItem<NotebookItem> NOTEBOOK = ITEMS.registerItem(
"notebook",
NotebookItem::new,
new Item.Properties().stacksTo(1)
);
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
"ink_vial",
InkVialItem::new,
new Item.Properties().stacksTo(16)
);
private ModItems() {
}
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.inventory.MenuType;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.common.extensions.IMenuTypeExtension;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
public final class ModMenus {
private static final DeferredRegister<MenuType<?>> MENUS =
DeferredRegister.create(Registries.MENU, Rivencraft.MOD_ID);
public static final DeferredHolder<MenuType<?>, MenuType<NotebookMenu>> NOTEBOOK =
MENUS.register("notebook", () -> IMenuTypeExtension.create(NotebookMenu::new));
private ModMenus() {
}
public static void register(IEventBus modEventBus) {
MENUS.register(modEventBus);
}
}
@@ -0,0 +1,88 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.world.item.ItemStack;
/** A bounded, position-preserving collection of Page stacks stored on a Notebook item. */
public record NotebookContents(List<ItemStack> pages) {
public static final int MAX_PAGES = 27;
public static final NotebookContents EMPTY = new NotebookContents(List.of());
public static final Codec<NotebookContents> CODEC = ItemStack.OPTIONAL_CODEC.listOf()
.validate(NotebookContents::validate)
.xmap(NotebookContents::new, NotebookContents::pages);
public static final StreamCodec<RegistryFriendlyByteBuf, NotebookContents> STREAM_CODEC = StreamCodec.of(
NotebookContents::encode, NotebookContents::decode);
public NotebookContents {
if (pages.size() > MAX_PAGES) {
throw new IllegalArgumentException("Notebook exceeds " + MAX_PAGES + " page positions");
}
List<ItemStack> copies = new ArrayList<>(pages.size());
for (ItemStack page : pages) {
validatePage(page).getOrThrow();
copies.add(page.copy());
}
pages = List.copyOf(copies);
}
public int pageCount() {
return (int)pages.stream().filter(page -> !page.isEmpty()).count();
}
public int unresolvedCount() {
return (int)pages.stream()
.filter(page -> !page.isEmpty()
&& PageContent.Kind.UNRESOLVED_SYMBOL == PageItem.content(page).kind())
.count();
}
private static DataResult<List<ItemStack>> validate(List<ItemStack> pages) {
if (pages.size() > MAX_PAGES) {
return DataResult.error(() -> "Notebook exceeds " + MAX_PAGES + " page positions");
}
for (ItemStack page : pages) {
DataResult<ItemStack> result = validatePage(page);
if (result.error().isPresent()) {
return DataResult.error(() -> result.error().orElseThrow().message());
}
}
return DataResult.success(pages);
}
private static DataResult<ItemStack> validatePage(ItemStack page) {
if (page.isEmpty()) {
return DataResult.success(page);
}
if (!page.is(ModItems.PAGE.get())) {
return DataResult.error(() -> "Notebook contains a non-Page item");
}
return page.getCount() == 1
? DataResult.success(page)
: DataResult.error(() -> "Notebook Page stacks must contain exactly one item");
}
private static void encode(RegistryFriendlyByteBuf buffer, NotebookContents contents) {
ByteBufCodecs.VAR_INT.encode(buffer, contents.pages.size());
contents.pages.forEach(page -> ItemStack.OPTIONAL_STREAM_CODEC.encode(buffer, page));
}
private static NotebookContents decode(RegistryFriendlyByteBuf buffer) {
int size = ByteBufCodecs.VAR_INT.decode(buffer);
if (size < 0 || size > MAX_PAGES) {
throw new IllegalArgumentException("Invalid Notebook page count " + size);
}
List<ItemStack> pages = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
pages.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(buffer));
}
return new NotebookContents(pages);
}
}
@@ -0,0 +1,77 @@
package net.dnorberg.rivencraft.writing;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Server-owned costs and the baseline blank-page-to-link-panel transaction. */
public final class WritingInkService {
private WritingInkService() {
}
public static int symbolCopyCost() {
return RivencraftConfig.symbolInkCost();
}
public static int linkPanelCost() {
return RivencraftConfig.linkPanelInkCost();
}
public static boolean canPrepareLinkPanel(Player player, InteractionHand vialHand) {
ItemStack vial = player.getItemInHand(vialHand);
ItemStack page = player.getItemInHand(other(vialHand));
int remaining = InkVialItem.remaining(vial);
return vial.is(ModItems.INK_VIAL.get()) && remaining >= linkPanelCost()
&& remaining <= InkVialItem.CAPACITY
&& page.is(ModItems.PAGE.get()) && PageItem.content(page).kind() == PageContent.Kind.BLANK;
}
public static boolean prepareLinkPanel(Player player, InteractionHand vialHand) {
if (player.level().isClientSide || !canPrepareLinkPanel(player, vialHand)) {
return false;
}
InteractionHand pageHand = other(vialHand);
ItemStack page = player.getItemInHand(pageHand);
ItemStack vial = player.getItemInHand(vialHand);
int newInk = InkVialItem.remaining(vial) - linkPanelCost();
ItemStack panel = PageItem.createLinkPanelPage();
ItemStack spentVial = newInk == 0
? new ItemStack(Items.GLASS_BOTTLE)
: new ItemStack(ModItems.INK_VIAL.get());
if (newInk > 0) {
spentVial.set(ModDataComponents.INK_REMAINING.get(), newInk);
}
// All validation and output construction is complete before changing either input.
if (page.getCount() == 1) {
player.setItemInHand(pageHand, panel);
} else {
page.shrink(1);
giveOrDrop(player, panel);
}
if (vial.getCount() == 1) {
player.setItemInHand(vialHand, spentVial);
} else {
vial.shrink(1);
giveOrDrop(player, spentVial);
}
return true;
}
private static InteractionHand other(InteractionHand hand) {
return hand == InteractionHand.MAIN_HAND ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
}
private static void giveOrDrop(Player player, ItemStack stack) {
if (!player.getInventory().add(stack)) {
player.drop(stack, false);
}
}
}
@@ -12,6 +12,15 @@
"item.rivencraft.page.link_panel": "Link Panel Page",
"item.rivencraft.page.link_panel.description": "The baseline panel required to assemble a Descriptive Book.",
"item.rivencraft.page.unresolved": "Unresolved Symbol Page",
"item.rivencraft.notebook": "Notebook",
"item.rivencraft.notebook.pages": "Pages: %s/%s",
"item.rivencraft.notebook.unresolved": "Unresolved pages: %s",
"item.rivencraft.ink_vial": "Ink Vial",
"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.",
"screen.rivencraft.notebook.search": "Search pages",
"screen.rivencraft.notebook.move_previous": "Move selected page left",
"screen.rivencraft.notebook.move_next": "Move selected page right",
"message.rivencraft.linking_book.bind_hint": "Sneak-use to bind this book to your current location",
"message.rivencraft.linking_book.unbound": "This Linking Book is unbound",
"message.rivencraft.linking_book.bound": "Linking Book bound as %s",
@@ -89,5 +98,6 @@
"screen.rivencraft.book.explore": "Explore",
"screen.rivencraft.book.exploring": "Exploring",
"screen.rivencraft.book.return_from_explore": "Right-click to return",
"itemGroup.rivencraft": "Rivencraft"
"itemGroup.rivencraft": "Rivencraft",
"itemGroup.rivencraft.pages": "Rivencraft Pages"
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/ink_vial"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/notebook"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,3 @@
{
"animation": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

@@ -0,0 +1,14 @@
{
"type": "minecraft:crafting_shapeless",
"category": "misc",
"ingredients": [
{ "item": "minecraft:black_dye" },
{ "item": "minecraft:black_dye" },
{ "item": "minecraft:glass_bottle" },
{ "item": "minecraft:water_bucket" }
],
"result": {
"count": 1,
"id": "rivencraft:ink_vial"
}
}
@@ -0,0 +1,21 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"key": {
"L": {
"item": "minecraft:leather"
},
"S": {
"item": "minecraft:string"
}
},
"pattern": [
" L ",
"S ",
" L "
],
"result": {
"count": 1,
"id": "rivencraft:notebook"
}
}