Add writing ink and baseline link panel preparation
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-24 04:38:05 -04:00
1 parent 143da0a940
commit d6fd32994f
16 files changed
+298 -3

No files matched your search

@@ -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();
}
}
@@ -11,6 +11,8 @@ 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;
@@ -200,6 +202,95 @@ public final class WritingDataGameTests {
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));
}
}
@@ -21,6 +21,7 @@ public final class ModCreativeTabs {
output.accept(ModItems.LINKING_BOOK.get());
output.accept(ModItems.PAGE.get());
output.accept(ModItems.NOTEBOOK.get());
output.accept(ModItems.INK_VIAL.get());
})
.build());
@@ -6,6 +6,7 @@ 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;
@@ -60,6 +61,12 @@ public final class ModDataComponents {
.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() {
}
@@ -5,6 +5,7 @@ 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;
@@ -33,6 +34,11 @@ public final class ModItems {
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,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);
}
}
}
@@ -15,6 +15,9 @@
"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.rename": "Rename selected page",
"screen.rivencraft.notebook.search": "Search pages",
"screen.rivencraft.notebook.rename_toggle": "Rename selected page (R); use left/right arrows to reorder",
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/ink_vial"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,3 @@
{
"animation": {}
}
@@ -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"
}
}