From 03fdf77996bc4e8b30df848ca16006b013c67c28 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 03:29:32 +0200 Subject: [PATCH 01/72] feat(machine): plans that say what a slot or tank will end up holding Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/machine/inventory/SlotPlan.java | 104 +++++++++++++++++ .../mc/lib/machine/inventory/TankPlan.java | 86 ++++++++++++++ .../moddev/mc/lib/smoke/MMDLibGameTests.java | 6 + .../moddev/mc/lib/smoke/SlotPlanSmoke.java | 110 ++++++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 src/main/java/zone/moddev/mc/lib/machine/inventory/SlotPlan.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/inventory/TankPlan.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/SlotPlanSmoke.java diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/SlotPlan.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/SlotPlan.java new file mode 100644 index 0000000..6eb93db --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/SlotPlan.java @@ -0,0 +1,104 @@ +package zone.moddev.mc.lib.machine.inventory; + +import org.jetbrains.annotations.Nullable; + +import net.minecraft.world.item.ItemStack; + +/** + * What a machine means to do to one item slot: take {@code take} of what it holds, then put {@code + * add} in. Reservations compare against the slot's end state, so items the machine is about to + * take are free for others and items it is about to add are not. + */ +public record SlotPlan(int take, ItemStack add) { + /** No plan: the slot behaves as if nothing were reserved. */ + public static final SlotPlan NONE = new SlotPlan(0, ItemStack.EMPTY); + + /** + * Copies {@code add}. + * + * @throws IllegalArgumentException if {@code take} is negative + */ + public SlotPlan { + if (take < 0) throw new IllegalArgumentException("Take is negative: " + take); + add = add.copy(); + } + + /** A copy of the stack to add. */ + @Override + public ItemStack add() { + return add.copy(); + } + + /** Whether this takes and adds nothing. */ + public boolean isNone() { + return take == 0 && add.isEmpty(); + } + + /** How many of {@code held} nobody has planned to take, and so are free to others. */ + public int free(ItemStack held) { + return Math.max(0, held.getCount() - take); + } + + /** + * What {@code held} becomes. Check {@link #legal} first: an {@code add} that cannot stack on the + * leftover is dropped, and the leftover comes back unchanged. + */ + public ItemStack endState(ItemStack held) { + var left = held.copy(); + left.setCount(free(held)); + if (add.isEmpty()) return left; + if (left.isEmpty()) return add.copy(); + if (!ItemStack.isSameItemSameComponents(left, add)) return left; + left.grow(add.getCount()); + return left; + } + + /** Whether the plan can run on {@code held}: enough to take, one stack, within the limit. */ + public boolean legal(ItemStack held, int slotLimit) { + if (take > held.getCount()) return false; + var left = free(held); + if (add.isEmpty()) return true; + if (left > 0 && !ItemStack.isSameItemSameComponents(held, add)) return false; + return left + add.getCount() <= Math.min(slotLimit, add.getMaxStackSize()); + } + + /** Whether {@code stack} is the item the slot holds now and will hold after the plan. */ + public boolean accepts(ItemStack held, ItemStack stack) { + if (stack.isEmpty()) return false; + if (!held.isEmpty() && !ItemStack.isSameItemSameComponents(held, stack)) return false; + var end = endState(held); + return end.isEmpty() || ItemStack.isSameItemSameComponents(end, stack); + } + + /** How many of {@code stack} may go in now without breaking the plan; 0 unless accepted. */ + public int room(ItemStack held, ItemStack stack, int slotLimit) { + if (!accepts(held, stack)) return 0; + var limit = Math.min(slotLimit, stack.getMaxStackSize()); + return Math.max(0, Math.min(limit - endState(held).getCount(), limit - held.getCount())); + } + + /** This plan and {@code other} together, or null if they add different items. */ + @Nullable + public SlotPlan plus(SlotPlan other) { + if (!add.isEmpty() + && !other.add.isEmpty() + && !ItemStack.isSameItemSameComponents(add, other.add)) { + return null; + } + var sum = add.isEmpty() ? other.add.copy() : add.copy(); + sum.setCount(add.getCount() + other.add.getCount()); + return new SlotPlan(take + other.take, sum); + } + + /** This plan without {@code other}, which must have been added with {@link #plus}. */ + public SlotPlan minus(SlotPlan other) { + var rest = add.copy(); + rest.setCount(Math.max(0, add.getCount() - other.add.getCount())); + return new SlotPlan(Math.max(0, take - other.take), rest); + } + + /** Whether {@code other} plans the same thing; records compare stacks by identity. */ + public boolean sameAs(SlotPlan other) { + return take == other.take && ItemStack.matches(add, other.add); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/TankPlan.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/TankPlan.java new file mode 100644 index 0000000..bc4c6f9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/TankPlan.java @@ -0,0 +1,86 @@ +package zone.moddev.mc.lib.machine.inventory; + +import org.jetbrains.annotations.Nullable; + +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; + +/** What a machine means to do to one tank, as {@link SlotPlan} does for an item slot. */ +public record TankPlan(int take, FluidAmount add) { + /** No plan: the tank behaves as if nothing were reserved. */ + public static final TankPlan NONE = new TankPlan(0, FluidAmount.EMPTY); + + /** + * @throws IllegalArgumentException if {@code take} is negative + */ + public TankPlan { + if (take < 0) throw new IllegalArgumentException("Take is negative: " + take); + } + + /** Whether this takes and adds nothing. */ + public boolean isNone() { + return take == 0 && add.isEmpty(); + } + + /** How much of {@code held} nobody has planned to take, and so is free to others. */ + public int free(FluidAmount held) { + return Math.max(0, held.amount() - take); + } + + /** + * What {@code held} becomes. Check {@link #legal} first: an {@code add} of another fluid is + * dropped, and the leftover comes back unchanged. + */ + public FluidAmount endState(FluidAmount held) { + var left = free(held); + if (left == 0) return add.isEmpty() ? FluidAmount.EMPTY : add; + if (add.isEmpty() || !held.sameFluid(add)) return held.withAmount(left); + return held.withAmount(left + add.amount()); + } + + /** Whether the plan can run on {@code held}: enough to take, one fluid, within capacity. */ + public boolean legal(FluidAmount held, int capacity) { + if (take > held.amount()) return false; + var left = free(held); + if (add.isEmpty()) return true; + if (left > 0 && !held.sameFluid(add)) return false; + return (long) left + add.amount() <= capacity; + } + + /** Whether {@code fluid} is the fluid the tank holds now and will hold after the plan. */ + public boolean accepts(FluidAmount held, FluidAmount fluid) { + if (fluid.isEmpty()) return false; + if (!held.isEmpty() && !held.sameFluid(fluid)) return false; + var end = endState(held); + return end.isEmpty() || end.sameFluid(fluid); + } + + /** How much of {@code fluid} may go in now without breaking the plan; 0 unless accepted. */ + public int room(FluidAmount held, FluidAmount fluid, int capacity) { + if (!accepts(held, fluid)) return 0; + return Math.max(0, Math.min(capacity - endState(held).amount(), capacity - held.amount())); + } + + /** This plan and {@code other} together, or null if they add different fluids. */ + @Nullable + public TankPlan plus(TankPlan other) { + if (!add.isEmpty() && !other.add.isEmpty() && !add.sameFluid(other.add)) return null; + var base = add.isEmpty() ? other.add : add; + var amount = add.amount() + other.add.amount(); + return new TankPlan( + take + other.take, amount == 0 ? FluidAmount.EMPTY : base.withAmount(amount)); + } + + /** This plan without {@code other}, which must have been added with {@link #plus}. */ + public TankPlan minus(TankPlan other) { + var rest = Math.max(0, add.amount() - other.add.amount()); + return new TankPlan( + Math.max(0, take - other.take), rest == 0 ? FluidAmount.EMPTY : add.withAmount(rest)); + } + + /** Whether {@code other} plans the same thing. */ + public boolean sameAs(TankPlan other) { + if (take != other.take) return false; + if (add.isEmpty() || other.add.isEmpty()) return add.isEmpty() == other.add.isEmpty(); + return add.sameFluid(other.add) && add.amount() == other.add.amount(); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index 84acd33..3d71135 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -94,6 +94,12 @@ public static void machineInventory(GameTestHelper helper) { helper.succeed(); } + @GameTest(template = EMPTY) + public static void slotPlanRules(GameTestHelper helper) { + SlotPlanSmoke.check(); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void machineRecipes(GameTestHelper helper) { MachineRecipeSmoke.check(helper.getLevel()); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/SlotPlanSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/SlotPlanSmoke.java new file mode 100644 index 0000000..10599cf --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/SlotPlanSmoke.java @@ -0,0 +1,110 @@ +package zone.moddev.mc.lib.smoke; + +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.material.Fluids; + +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; +import zone.moddev.mc.lib.machine.inventory.TankPlan; + +/** Checks the rules that say what a planned slot or tank holds, offers and accepts. */ +final class SlotPlanSmoke { + private SlotPlanSmoke() {} + + static void check() { + var none = ItemStack.EMPTY; + + var take1 = new SlotPlan(1, none); + var fifty = iron(50); + require(take1.free(fifty) == 49, "free 49"); + require(take1.accepts(fifty, iron(1)) && take1.room(fifty, iron(1), 64) == 14, "iron room 14"); + require(!take1.accepts(fifty, gold(1)) && take1.room(fifty, gold(1), 64) == 0, "gold refused"); + require(take1.endState(fifty).getCount() == 49, "end 49"); + require(take1.endState(iron(1)).isEmpty(), "end empty once the player leaves one"); + + var swap = new SlotPlan(1, gold(1)); + var one = iron(1); + require(swap.free(one) == 0, "swap free"); + require(!swap.accepts(one, iron(1)) && !swap.accepts(one, gold(1)), "swap refuses both"); + require(swap.legal(one, 64) && swap.endState(one).is(Items.GOLD_INGOT), "swap ends as gold"); + require(!swap.legal(iron(2), 64), "partial stack is not legal"); + require(!swap.legal(none, 64), "cannot take from an empty slot"); + + var top = new SlotPlan(1, iron(2)); + var sixtyThree = iron(63); + require(top.free(sixtyThree) == 62, "top free"); + require(top.accepts(sixtyThree, iron(1)) && top.room(sixtyThree, iron(1), 64) == 0, "top full"); + require(top.legal(sixtyThree, 64), "top legal"); + require(top.endState(sixtyThree).getCount() == 64, "top ends full"); + require(!top.legal(sixtyThree, 16), "limit makes it illegal"); + + var addGold = new SlotPlan(0, gold(1)); + require(addGold.free(none) == 0, "add free"); + require(addGold.accepts(none, gold(1)) && addGold.room(none, gold(1), 64) == 63, "gold 63"); + require(!addGold.accepts(none, iron(1)), "iron refused"); + require(addGold.room(none, iron(1), 64) == 0, "iron no room"); + + require(take1.accepts(one, iron(1)) && take1.room(one, iron(1), 64) == 63, "emptied slot 63"); + require(new SlotPlan(0, none).room(iron(10), iron(1), 16) == 6, "limit 16 caps room"); + require(new SlotPlan(0, none).room(none, iron(1), 16) == 16, "limit 16 caps an empty slot"); + + require(new SlotPlan(0, iron(1)).plus(new SlotPlan(0, gold(1))) == null, "different adds"); + var sum = new SlotPlan(1, none).plus(new SlotPlan(2, none)); + require(sum != null && sum.take() == 3 && sum.add().isEmpty(), "takes add up"); + var a = new SlotPlan(1, iron(2)); + var b = new SlotPlan(2, iron(3)); + var ab = a.plus(b); + require(ab != null && ab.take() == 3 && ab.add().getCount() == 5, "plus"); + require(ab.minus(b).sameAs(a), "minus undoes plus"); + var c = new SlotPlan(0, gold(1)); + var ac = SlotPlan.NONE.plus(c); + require(ac != null && ac.sameAs(c) && ac.minus(c).isNone(), "plus onto none"); + require(!a.sameAs(b) && a.sameAs(new SlotPlan(1, iron(2))), "sameAs"); + require(new SlotPlan(0, none).isNone() && SlotPlan.NONE.isNone() && !a.isNone(), "isNone"); + + var source = iron(2); + var copy = new SlotPlan(0, source); + source.setCount(9); + require(copy.add().getCount() == 2, "add is copied in"); + copy.add().setCount(9); + require(copy.add().getCount() == 2, "add is copied out"); + + tank(); + } + + private static void tank() { + var none = DataComponentPatch.EMPTY; + var water = new FluidAmount(Fluids.WATER.builtInRegistryHolder(), none, 1000); + var lava = new FluidAmount(Fluids.LAVA.builtInRegistryHolder(), none, 1000); + var plan = new TankPlan(250, FluidAmount.EMPTY); + require(plan.free(water) == 750, "tank free"); + require(plan.room(water, water, 4000) == 3000, "tank water room"); + require(!plan.accepts(water, lava) && plan.room(water, lava, 4000) == 0, "tank lava refused"); + require(plan.endState(water).amount() == 750, "tank end"); + + var swap = new TankPlan(1000, lava); + require(swap.legal(water, 4000) && swap.endState(water).sameFluid(lava), "tank swap"); + require(!new TankPlan(500, lava).legal(water, 4000), "tank partial swap illegal"); + require(!new TankPlan(0, lava).legal(water, 4000), "tank mix illegal"); + require(!new TankPlan(0, water).legal(water, 1500), "tank over capacity illegal"); + require(new TankPlan(0, water).plus(new TankPlan(0, lava)) == null, "tank different adds"); + var both = plan.plus(new TankPlan(100, water)); + require(both != null && both.take() == 350 && both.add().amount() == 1000, "tank plus"); + require(both.minus(new TankPlan(100, water)).sameAs(plan), "tank minus"); + require(TankPlan.NONE.isNone() && !plan.isNone(), "tank isNone"); + } + + private static ItemStack iron(int count) { + return new ItemStack(Items.IRON_INGOT, count); + } + + private static ItemStack gold(int count) { + return new ItemStack(Items.GOLD_INGOT, count); + } + + private static void require(boolean condition, String what) { + if (!condition) throw new AssertionError("Slot plan smoke failed: " + what); + } +} From 3888fec1ed6bca0bc11d02c83588df9f1690a0be Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 03:36:58 +0200 Subject: [PATCH 02/72] refactor(machine): move the fluid conversions to FluidStacks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/machine/neoforge/FluidStacks.java | 22 +++++++++++++++++++ .../lib/machine/neoforge/StorageGroups.java | 18 +++++---------- 2 files changed, 27 insertions(+), 13 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/FluidStacks.java diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/FluidStacks.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/FluidStacks.java new file mode 100644 index 0000000..c4ee5d9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/FluidStacks.java @@ -0,0 +1,22 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import net.neoforged.neoforge.fluids.FluidStack; + +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; + +/** Converts between the machine core's {@link FluidAmount} and NeoForge's {@link FluidStack}. */ +public final class FluidStacks { + private FluidStacks() {} + + /** The stack for {@code fluid}; empty for {@link FluidAmount#EMPTY}. */ + public static FluidStack stack(FluidAmount fluid) { + return new FluidStack(fluid.fluid(), fluid.amount(), fluid.components()); + } + + /** The amount for {@code stack}; {@link FluidAmount#EMPTY} for an empty stack. */ + public static FluidAmount amount(FluidStack stack) { + return stack.isEmpty() + ? FluidAmount.EMPTY + : new FluidAmount(stack.getFluidHolder(), stack.getComponentsPatch(), stack.getAmount()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java index 0062b51..c290aef 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java @@ -136,7 +136,9 @@ public int tanks() { @Override public FluidAmount peek(int tank) { - return valid(tank) ? amount(storage.tank(indexes[tank]).getFluid()) : FluidAmount.EMPTY; + return valid(tank) + ? FluidStacks.amount(storage.tank(indexes[tank]).getFluid()) + : FluidAmount.EMPTY; } @Override @@ -151,7 +153,7 @@ public int extract(int tank, int amount, MachineTx tx) { @Override public int insert(FluidAmount fluid, MachineTx tx) { if (fluid.isEmpty()) return 0; - var remaining = stack(fluid); + var remaining = FluidStacks.stack(fluid); for (boolean merging : new boolean[] {true, false}) for (int index : indexes) { if (remaining.isEmpty()) break; @@ -173,7 +175,7 @@ public boolean fits(List fluids, long[] taken) { } for (var fluid : fluids) { if (fluid.isEmpty()) continue; - var stack = stack(fluid); + var stack = FluidStacks.stack(fluid); int left = fluid.amount(); for (boolean merging : new boolean[] {true, false}) for (int i = 0; i < indexes.length && left > 0; i++) { @@ -204,16 +206,6 @@ private void track(int index, MachineTx tx) { return () -> storage.tank(index).setFluid(before); }); } - - private static FluidStack stack(FluidAmount fluid) { - return new FluidStack(fluid.fluid(), fluid.amount(), fluid.components()); - } - - private static FluidAmount amount(FluidStack stack) { - return stack.isEmpty() - ? FluidAmount.EMPTY - : new FluidAmount(stack.getFluidHolder(), stack.getComponentsPatch(), stack.getAmount()); - } } private record EnergyStore(EnergyBuffer buffer) implements EnergyGroup { From 537005c632632e366847f486500eabea03d45f0e Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 03:36:58 +0200 Subject: [PATCH 03/72] feat(capabilities): storages hold reservations and obey them Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/api/capabilities/FluidStorage.java | 63 +++++- .../mc/lib/api/capabilities/ItemStorage.java | 65 +++++- .../lib/api/capabilities/ReservedSlots.java | 15 ++ .../moddev/mc/lib/smoke/MMDLibGameTests.java | 6 + .../mc/lib/smoke/ReservedStorageSmoke.java | 193 ++++++++++++++++++ 5 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/lib/api/capabilities/ReservedSlots.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedStorageSmoke.java diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java index 468c337..cf94f14 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java @@ -15,6 +15,9 @@ import net.neoforged.neoforge.fluids.capability.IFluidHandler; import net.neoforged.neoforge.fluids.capability.templates.FluidTank; +import zone.moddev.mc.lib.machine.inventory.TankPlan; +import zone.moddev.mc.lib.machine.neoforge.FluidStacks; + /** * A machine's tanks as one {@link IFluidHandler}, each tank with a {@link Role}, capacity and * filter, and a change callback. The owning block entity works on {@link #tank} directly, which @@ -27,6 +30,7 @@ public class FluidStorage implements IFluidHandler, INBTSerializable { private final List tanks = new ArrayList<>(); private final List roles = new ArrayList<>(); + private final List plans = new ArrayList<>(); private Runnable onChange = () -> {}; /** A storage with no tanks; add them with {@link #addTank}. */ @@ -40,14 +44,36 @@ public FluidStorage onChange(Runnable onChange) { /** Adds a tank and returns its index. */ public int addTank(int capacity, Role role, Predicate filter) { + int index = tanks.size(); tanks.add( new FluidTank(capacity, filter) { @Override protected void onContentsChanged() { onChange.run(); } + + @Override + public int fill(FluidStack resource, FluidAction action) { + var plan = plans.get(index); + if (plan.isNone()) return super.fill(resource, action); + int room = + plan.room( + FluidStacks.amount(getFluid()), FluidStacks.amount(resource), getCapacity()); + if (room <= 0) return 0; + int amount = Math.min(resource.getAmount(), room); + return super.fill(resource.copyWithAmount(amount), action); + } + + @Override + public FluidStack drain(int maxDrain, FluidAction action) { + var plan = plans.get(index); + if (plan.isNone()) return super.drain(maxDrain, action); + int free = plan.free(FluidStacks.amount(getFluid())); + return free <= 0 ? FluidStack.EMPTY : super.drain(Math.min(maxDrain, free), action); + } }); roles.add(role); + plans.add(TankPlan.NONE); return tanks.size() - 1; } @@ -61,6 +87,33 @@ public FluidTank tank(int index) { return tanks.get(index); } + /** What the machine plans for tank {@code tank}. */ + public TankPlan plan(int tank) { + return plans.get(tank); + } + + /** Replaces the plan with no checks, for undoing and loading; use {@link #reserve} otherwise. */ + public void setPlan(int tank, TankPlan plan) { + plans.set(tank, plan); + } + + /** + * Adds {@code plan} to what is planned for the tank. Returns false and changes nothing if the + * plans cannot be merged or the result is not legal for what the tank holds now. + */ + public boolean reserve(int tank, TankPlan plan) { + var merged = plans.get(tank).plus(plan); + var held = FluidStacks.amount(tanks.get(tank).getFluid()); + if (merged == null || !merged.legal(held, tanks.get(tank).getCapacity())) return false; + plans.set(tank, merged); + return true; + } + + /** Takes {@code plan}, which must have been reserved, out of the tank's plan. */ + public void release(int tank, TankPlan plan) { + plans.set(tank, plans.get(tank).minus(plan)); + } + /** The role of tank {@code tank}. */ public Role role(int tank) { return roles.get(tank); @@ -165,11 +218,11 @@ private int fill(FluidStack resource, FluidAction action, Predicate allowe for (int i = 0; i < tanks.size() && target < 0; i++) if (allowed.test(roles.get(i)) && FluidStack.isSameFluidSameComponents(tanks.get(i).getFluid(), resource) - && tanks.get(i).getSpace() > 0) target = i; + && tanks.get(i).fill(resource, FluidAction.SIMULATE) > 0) target = i; for (int i = 0; i < tanks.size() && target < 0; i++) if (allowed.test(roles.get(i)) && tanks.get(i).isEmpty() - && tanks.get(i).isFluidValid(resource)) target = i; + && tanks.get(i).fill(resource, FluidAction.SIMULATE) > 0) target = i; return target < 0 ? 0 : tanks.get(target).fill(resource, action); } @@ -177,14 +230,16 @@ private FluidStack drain(FluidStack resource, FluidAction action, Predicate allowed) { for (int i = 0; i < tanks.size(); i++) - if (allowed.test(roles.get(i)) && !tanks.get(i).isEmpty()) + if (allowed.test(roles.get(i)) + && !tanks.get(i).drain(maxDrain, FluidAction.SIMULATE).isEmpty()) return tanks.get(i).drain(maxDrain, action); return FluidStack.EMPTY; } diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java index 8ba67ea..14f6bc6 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java @@ -13,6 +13,8 @@ import net.neoforged.neoforge.items.IItemHandler; import net.neoforged.neoforge.items.ItemStackHandler; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; + /** * A machine inventory: an {@link ItemStackHandler} with a {@link Role}, filter and limit per slot, * and a change callback. The owning block entity and its screen use it directly; automation gets a @@ -22,9 +24,10 @@ * refuse placement, so players cannot put items into outputs. The machine fills outputs with {@link * #insertResult}. */ -public class ItemStorage extends ItemStackHandler { +public class ItemStorage extends ItemStackHandler implements ReservedSlots { private final Role[] roles; private final int[] limits; + private final SlotPlan[] plans; @SuppressWarnings("unchecked") private final Predicate[] filters; @@ -41,9 +44,11 @@ public ItemStorage(int size) { super(size); roles = new Role[size]; limits = new int[size]; + plans = new SlotPlan[size]; filters = new Predicate[size]; Arrays.fill(roles, Role.STORAGE); Arrays.fill(limits, Item.ABSOLUTE_MAX_STACK_SIZE); + Arrays.fill(plans, SlotPlan.NONE); Arrays.fill(filters, (Predicate) stack -> true); } @@ -80,6 +85,49 @@ public Role role(int slot) { return roles[checked(slot)]; } + /** What the machine plans for slot {@code slot}. */ + public SlotPlan plan(int slot) { + return plans[checked(slot)]; + } + + /** Replaces the plan with no checks, for undoing and loading; use {@link #reserve} otherwise. */ + public void setPlan(int slot, SlotPlan plan) { + plans[checked(slot)] = plan; + } + + /** + * Adds {@code plan} to what is planned for the slot. Returns false and changes nothing if the + * plans cannot be merged or the result is not legal for what the slot holds now. + */ + public boolean reserve(int slot, SlotPlan plan) { + var merged = plans[checked(slot)].plus(plan); + if (merged == null || !merged.legal(getStackInSlot(slot), getSlotLimit(slot))) return false; + plans[slot] = merged; + return true; + } + + /** Takes {@code plan}, which must have been reserved, out of the slot's plan. */ + public void release(int slot, SlotPlan plan) { + plans[checked(slot)] = plans[slot].minus(plan); + } + + @Override + public boolean hasPlan(int slot) { + return inRange(slot) && !plans[slot].isNone(); + } + + @Override + public int freeToTake(int slot) { + return plans[checked(slot)].free(getStackInSlot(slot)); + } + + @Override + public int maxHeld(int slot, ItemStack stack) { + if (!hasPlan(slot)) return Math.min(getSlotLimit(slot), stack.getMaxStackSize()); + var held = getStackInSlot(slot); + return held.getCount() + plans[slot].room(held, stack, getSlotLimit(slot)); + } + /** Inserts the machine's own product, ignoring roles and filters. Returns what did not fit. */ public ItemStack insertResult(int slot, ItemStack stack, boolean simulate) { bypass = true; @@ -106,7 +154,20 @@ public void setSize(int size) { @Override public boolean isItemValid(int slot, ItemStack stack) { - return bypass || (roles[slot] != Role.OUTPUT && filters[slot].test(stack)); + boolean allowed = bypass || (roles[slot] != Role.OUTPUT && filters[slot].test(stack)); + return allowed && (!hasPlan(slot) || plans[slot].accepts(getStackInSlot(slot), stack)); + } + + @Override + public ItemStack extractItem(int slot, int amount, boolean simulate) { + validateSlotIndex(slot); + int allowed = hasPlan(slot) ? Math.min(amount, freeToTake(slot)) : amount; + return super.extractItem(slot, allowed, simulate); + } + + @Override + protected int getStackLimit(int slot, ItemStack stack) { + return maxHeld(slot, stack); } @Override diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/ReservedSlots.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/ReservedSlots.java new file mode 100644 index 0000000..8c32050 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/ReservedSlots.java @@ -0,0 +1,15 @@ +package zone.moddev.mc.lib.api.capabilities; + +import net.minecraft.world.item.ItemStack; + +/** What a menu needs to know about a slot whose contents a machine has made plans for. */ +public interface ReservedSlots { + /** Whether the machine has planned something for this slot; false if it does not exist. */ + boolean hasPlan(int slot); + + /** How many items a player may take from this slot now without breaking the plan. */ + int freeToTake(int slot); + + /** The total this slot may hold of {@code stack}, as {@code Slot.getMaxStackSize(stack)}. */ + int maxHeld(int slot, ItemStack stack); +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index 3d71135..b7468d2 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -100,6 +100,12 @@ public static void slotPlanRules(GameTestHelper helper) { helper.succeed(); } + @GameTest(template = EMPTY) + public static void reservedStorage(GameTestHelper helper) { + ReservedStorageSmoke.check(helper.getLevel(), helper.absolutePos(CENTER)); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void machineRecipes(GameTestHelper helper) { MachineRecipeSmoke.check(helper.getLevel()); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedStorageSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedStorageSmoke.java new file mode 100644 index 0000000..975294e --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedStorageSmoke.java @@ -0,0 +1,193 @@ +package zone.moddev.mc.lib.smoke; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.material.Fluids; +import net.minecraft.world.phys.AABB; + +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; + +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; +import zone.moddev.mc.lib.machine.inventory.TankPlan; + +/** Checks that storages hold reservation plans and refuse what would break them. */ +final class ReservedStorageSmoke { + private ReservedStorageSmoke() {} + + static void check(ServerLevel level, BlockPos pos) { + var none = ItemStack.EMPTY; + + var taking = new ItemStorage(1); + taking.setStackInSlot(0, iron(50)); + taking.setPlan(0, new SlotPlan(1, none)); + require(taking.hasPlan(0) && !taking.hasPlan(1) && !taking.hasPlan(-1), "hasPlan"); + require(taking.freeToTake(0) == 49, "free to take"); + var viewed = taking.view(IoMode.BOTH).extractItem(0, 64, true); + require(viewed.getCount() == 49, "view takes 49"); + var taken = taking.extractItem(0, 64, false); + require(taken.getCount() == 49 && taking.getStackInSlot(0).getCount() == 1, "takes 49"); + require(taking.extractItem(0, 64, false).isEmpty(), "the planned one stays"); + + var jam = new ItemStorage(1); + jam.setStackInSlot(0, iron(1)); + jam.setPlan(0, new SlotPlan(1, gold(1))); + var jamView = jam.view(IoMode.BOTH); + require(jamView.insertItem(0, iron(1), false).getCount() == 1, "no iron into the jam"); + require(jamView.insertItem(0, gold(1), false).getCount() == 1, "no gold into the jam"); + require(!jamView.isItemValid(0, iron(1)) && !jam.isItemValid(0, gold(1)), "invalid both"); + + var rows = new ItemStorage(1); + rows.setStackInSlot(0, iron(50)); + rows.setPlan(0, new SlotPlan(1, none)); + require(rows.isItemValid(0, iron(1)) && !rows.isItemValid(0, gold(1)), "rows valid"); + require(rows.insertItem(0, iron(30), true).getCount() == 16, "room is 14"); + rows.setStackInSlot(0, iron(63)); + rows.setPlan(0, new SlotPlan(1, iron(2))); + require(rows.insertItem(0, iron(5), true).getCount() == 5, "top slot refuses"); + rows.setPlan(0, new SlotPlan(0, none)); + require(rows.insertItem(0, iron(5), true).getCount() == 4, "empty plan, 1 of room"); + + var own = new ItemStorage(1); + own.setStackInSlot(0, iron(63)); + own.setPlan(0, new SlotPlan(1, iron(2))); + require(own.insertResult(0, iron(1), true).getCount() == 1, "insertResult respects plans"); + require(own.insertResult(0, iron(1), false).getCount() == 1, "and when it runs"); + require(own.getStackInSlot(0).getCount() == 63, "nothing moved"); + own.setStackInSlot(0, gold(3)); + require(own.getStackInSlot(0).is(Items.GOLD_INGOT), "setStackInSlot ignores plans"); + + var res = new ItemStorage(1); + res.setStackInSlot(0, iron(1)); + require(res.reserve(0, new SlotPlan(1, gold(1))), "reserve take and add"); + require(!res.reserve(0, new SlotPlan(0, iron(2))), "different add refused"); + require(res.plan(0).sameAs(new SlotPlan(1, gold(1))), "refusal keeps the plan"); + require(!res.reserve(0, new SlotPlan(0, gold(64))), "over the limit refused"); + require(res.plan(0).sameAs(new SlotPlan(1, gold(1))), "limit refusal keeps the plan"); + require(res.reserve(0, new SlotPlan(0, gold(2))), "same item merges"); + require(res.plan(0).add().getCount() == 3, "merged to 3"); + res.release(0, new SlotPlan(0, gold(2))); + res.release(0, new SlotPlan(1, gold(1))); + require(res.plan(0).isNone() && !res.hasPlan(0), "release returns to none"); + require(!res.reserve(0, new SlotPlan(2, none)), "cannot take more than held"); + var limited = new ItemStorage(1).limit(16, 0); + require(!limited.reserve(0, new SlotPlan(0, iron(17))), "slot limit applies"); + + var plain = new ItemStorage(1).limit(16, 0); + require(plain.maxHeld(0, iron(1)) == 16, "no plan: slot limit"); + require(new ItemStorage(1).maxHeld(0, new ItemStack(Items.ENDER_PEARL)) == 16, "stack max"); + taking.setStackInSlot(0, iron(50)); + taking.setPlan(0, new SlotPlan(1, none)); + require(taking.maxHeld(0, iron(1)) == 64, "planned: total held"); + require(taking.maxHeld(0, gold(1)) == 50, "planned: other item gains no room"); + + var changes = new int[1]; + var quiet = new ItemStorage(2).onChange(() -> changes[0]++); + quiet.setStackInSlot(0, iron(10)); + changes[0] = 0; + var quietView = quiet.view(IoMode.BOTH); + quiet.setPlan(0, new SlotPlan(1, none)); + quiet.extractItem(0, 5, true); + quietView.extractItem(0, 5, true); + quiet.insertItem(0, iron(5), true); + quietView.insertItem(1, gold(5), true); + quiet.isItemValid(0, iron(1)); + quiet.maxHeld(0, iron(1)); + quiet.reserve(1, new SlotPlan(0, gold(1))); + quiet.release(1, new SlotPlan(0, gold(1))); + require(changes[0] == 0, "no onChange for simulations and plans: " + changes[0]); + + var broken = new ItemStorage(2); + broken.setStackInSlot(0, iron(10)); + broken.setStackInSlot(1, gold(2)); + broken.setPlan(0, new SlotPlan(4, iron(3))); + broken.setPlan(1, new SlotPlan(0, gold(1))); + broken.dropAll(level, pos); + require(broken.getStackInSlot(0).isEmpty() && broken.getStackInSlot(1).isEmpty(), "emptied"); + int dropped = 0; + for (var drop : level.getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(2))) { + dropped += drop.getItem().getCount(); + drop.discard(); + } + require(dropped == 12, "dropped everything, planned or not: " + dropped); + + tanks(); + } + + private static void tanks() { + var none = DataComponentPatch.EMPTY; + var water = new FluidAmount(Fluids.WATER.builtInRegistryHolder(), none, 1000); + var lava = new FluidAmount(Fluids.LAVA.builtInRegistryHolder(), none, 1000); + + var one = new FluidStorage(); + one.addTank(4000, Role.STORAGE); + one.tank(0).setFluid(new FluidStack(Fluids.WATER, 1000)); + one.setPlan(0, new TankPlan(250, FluidAmount.EMPTY)); + require(one.drain(4000, FluidAction.SIMULATE).getAmount() == 750, "handler drain 750"); + var byFluid = one.drain(new FluidStack(Fluids.WATER, 4000), FluidAction.SIMULATE); + require(byFluid.getAmount() == 750, "handler drain by fluid 750"); + require(one.tank(0).drain(4000, FluidAction.EXECUTE).getAmount() == 750, "tank drain 750"); + require(one.tank(0).getFluidAmount() == 250, "250 stay"); + one.tank(0).setFluid(new FluidStack(Fluids.WATER, 1000)); + require(one.tank(0).getFluidAmount() == 1000, "setFluid ignores plans"); + + var swap = new FluidStorage(); + swap.addTank(4000, Role.STORAGE); + swap.tank(0).setFluid(new FluidStack(Fluids.WATER, 1000)); + swap.setPlan(0, new TankPlan(1000, lava)); + require(swap.tank(0).fill(new FluidStack(Fluids.LAVA, 100), FluidAction.EXECUTE) == 0, "lava"); + require(swap.tank(0).fill(new FluidStack(Fluids.WATER, 100), FluidAction.EXECUTE) == 0, "wet"); + require(swap.tank(0).getFluidAmount() == 1000, "unchanged"); + + var two = new FluidStorage(); + two.addTank(1000, Role.STORAGE); + two.addTank(1000, Role.STORAGE); + two.tank(0).setFluid(new FluidStack(Fluids.WATER, 1000)); + two.tank(1).setFluid(new FluidStack(Fluids.WATER, 1000)); + two.setPlan(0, new TankPlan(1000, FluidAmount.EMPTY)); + var drained = two.drain(100, FluidAction.EXECUTE); + require(drained.getAmount() == 100 && two.tank(0).getFluidAmount() == 1000, "drain skips"); + require(two.tank(1).getFluidAmount() == 900, "drain took from the second"); + var named = two.drain(new FluidStack(Fluids.WATER, 100), FluidAction.EXECUTE); + require(named.getAmount() == 100 && two.tank(0).getFluidAmount() == 1000, "named skips"); + + two.tank(0).setFluid(new FluidStack(Fluids.WATER, 500)); + two.tank(1).setFluid(new FluidStack(Fluids.WATER, 500)); + two.setPlan(0, new TankPlan(0, water.withAmount(500))); + require(two.fill(new FluidStack(Fluids.WATER, 100), FluidAction.EXECUTE) == 100, "fill 100"); + require(two.tank(0).getFluidAmount() == 500, "first tank room is reserved"); + require(two.tank(1).getFluidAmount() == 600, "fill went to the second"); + + var reserve = new FluidStorage(); + reserve.addTank(1000, Role.STORAGE); + reserve.tank(0).setFluid(new FluidStack(Fluids.WATER, 500)); + require(!reserve.reserve(0, new TankPlan(300, lava)), "partial swap refused"); + require(reserve.reserve(0, new TankPlan(0, water.withAmount(400))), "reserve"); + require(!reserve.reserve(0, new TankPlan(0, lava)), "different fluid refused"); + require(!reserve.reserve(0, new TankPlan(0, water.withAmount(200))), "over capacity refused"); + require(reserve.plan(0).sameAs(new TankPlan(0, water.withAmount(400))), "plan kept"); + reserve.release(0, new TankPlan(0, water.withAmount(400))); + require(reserve.plan(0).isNone(), "released"); + } + + private static ItemStack iron(int count) { + return new ItemStack(Items.IRON_INGOT, count); + } + + private static ItemStack gold(int count) { + return new ItemStack(Items.GOLD_INGOT, count); + } + + private static void require(boolean condition, String what) { + if (!condition) throw new AssertionError("Reserved storage smoke failed: " + what); + } +} From a25a5b8805cda097a01641d1a792b33193aae049 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 03:44:18 +0200 Subject: [PATCH 04/72] test(capabilities): cover partial-stack refusal and the empty reserved slot Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../moddev/mc/lib/smoke/ReservedStorageSmoke.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedStorageSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedStorageSmoke.java index 975294e..1e4d302 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedStorageSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedStorageSmoke.java @@ -57,6 +57,13 @@ static void check(ServerLevel level, BlockPos pos) { rows.setPlan(0, new SlotPlan(0, none)); require(rows.insertItem(0, iron(5), true).getCount() == 4, "empty plan, 1 of room"); + var empty = new ItemStorage(1); + empty.setPlan(0, new SlotPlan(0, gold(1))); + require(empty.insertItem(0, iron(1), true).getCount() == 1, "empty slot refuses iron"); + require(!empty.isItemValid(0, iron(1)) && empty.isItemValid(0, gold(1)), "empty validity"); + require(empty.insertItem(0, gold(64), false).getCount() == 1, "empty slot takes 63 gold"); + require(empty.getStackInSlot(0).getCount() == 63, "63 gold held"); + var own = new ItemStorage(1); own.setStackInSlot(0, iron(63)); own.setPlan(0, new SlotPlan(1, iron(2))); @@ -79,6 +86,10 @@ static void check(ServerLevel level, BlockPos pos) { res.release(0, new SlotPlan(1, gold(1))); require(res.plan(0).isNone() && !res.hasPlan(0), "release returns to none"); require(!res.reserve(0, new SlotPlan(2, none)), "cannot take more than held"); + var partial = new ItemStorage(1); + partial.setStackInSlot(0, iron(2)); + require(!partial.reserve(0, new SlotPlan(1, gold(1))), "partial stack swap refused"); + require(partial.plan(0).isNone(), "partial refusal leaves no plan"); var limited = new ItemStorage(1).limit(16, 0); require(!limited.reserve(0, new SlotPlan(0, iron(17))), "slot limit applies"); From 00d0ba4465257ba07aa3cc2b8367451461794781 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 05:34:21 +0200 Subject: [PATCH 05/72] feat(machine): groups place, reserve and free by plan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/machine/inventory/FluidGroup.java | 48 ++++- .../mc/lib/machine/inventory/ItemGroup.java | 48 ++++- .../lib/machine/neoforge/StorageGroups.java | 184 ++++++++++++++---- 3 files changed, 236 insertions(+), 44 deletions(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java index 08f5496..f1f3413 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java @@ -2,6 +2,8 @@ import java.util.List; +import org.jetbrains.annotations.Nullable; + import zone.moddev.mc.lib.machine.ingredient.FluidAmount; /** Fluid tanks in a {@link MachineInventory}; amounts in mB. */ @@ -12,12 +14,42 @@ public non-sealed interface FluidGroup extends SlotGroup { /** What {@code tank} holds; {@link FluidAmount#EMPTY} outside the group. */ FluidAmount peek(int tank); - /** Drains up to {@code amount} mB from {@code tank}; returns how much was drained. */ + /** How many mB in {@code tank} no plan takes, so they are free to use; 0 outside the group. */ + int free(int tank); + + /** + * Drains up to {@code amount} mB from {@code tank}, never more than {@link #free}; returns how + * much was drained. + */ int extract(int tank, int amount, MachineTx tx); - /** Fills {@code fluid}, into tanks of that fluid first and then empty ones; returns mB filled. */ + /** + * Fills {@code fluid}, into tanks of that fluid first and then empty ones, without breaking any + * plan; returns mB filled. + */ int insert(FluidAmount fluid, MachineTx tx); + /** Like {@link #insert(FluidAmount, MachineTx)}, but into {@code tank} only. */ + int insert(int tank, FluidAmount fluid, MachineTx tx); + + /** + * Adds {@code plan} to what is planned for {@code tank}. Returns false and changes nothing if the + * plans cannot be merged or the result is not legal for what the tank holds. Undone if {@code tx} + * closes without commit. + */ + boolean reserve(int tank, TankPlan plan, MachineTx tx); + + /** + * Adds {@code plan} back to the tank's plan without checking that it is legal, for undoing a + * {@link #release}. + * + * @throws IllegalStateException if the plans cannot be merged + */ + void restore(int tank, TankPlan plan, MachineTx tx); + + /** Takes {@code plan}, which must have been reserved, out of the tank's plan. */ + void release(int tank, TankPlan plan, MachineTx tx); + /** Whether all of {@code fluids} would fit together. Changes nothing. */ default boolean fits(List fluids) { return fits(fluids, new long[tanks()]); @@ -27,5 +59,15 @@ default boolean fits(List fluids) { * Whether all of {@code fluids} would fit together once {@code taken[tank]} mB are gone from each * tank. Changes nothing. */ - boolean fits(List fluids, long[] taken); + default boolean fits(List fluids, long[] taken) { + return place(fluids, taken) != null; + } + + /** + * Where {@code fluids} would land once {@code taken[tank]} mB are gone from each tank: per fluid, + * per tank, how many mB go there. Room is measured against each tank's end state, and a tank + * whose plan is not legal takes nothing. Null if they do not all fit. Changes nothing. + */ + @Nullable + int[][] place(List fluids, long[] taken); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java index ad9048e..66a8b2e 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java @@ -2,6 +2,8 @@ import java.util.List; +import org.jetbrains.annotations.Nullable; + import net.minecraft.world.item.ItemStack; /** Item slots in a {@link MachineInventory}. */ @@ -12,15 +14,42 @@ public non-sealed interface ItemGroup extends SlotGroup { /** A copy of the stack in {@code slot}; empty outside the group. */ ItemStack peek(int slot); - /** Takes up to {@code count} items from {@code slot}; returns how many were taken. */ + /** How many items in {@code slot} no plan takes, so they are free to use; 0 outside the group. */ + int free(int slot); + + /** + * Takes up to {@code count} items from {@code slot}, never more than {@link #free}; returns how + * many were taken. + */ int extract(int slot, int count, MachineTx tx); /** - * Inserts {@code stack}, onto matching stacks first and then into empty slots; returns how many - * fitted. {@code stack} itself is not changed. + * Inserts {@code stack}, onto matching stacks first and then into empty slots, without breaking + * any plan; returns how many fitted. {@code stack} itself is not changed. */ int insert(ItemStack stack, MachineTx tx); + /** Like {@link #insert(ItemStack, MachineTx)}, but into {@code slot} only. */ + int insert(int slot, ItemStack stack, MachineTx tx); + + /** + * Adds {@code plan} to what is planned for {@code slot}. Returns false and changes nothing if the + * plans cannot be merged or the result is not legal for what the slot holds. Undone if {@code tx} + * closes without commit. + */ + boolean reserve(int slot, SlotPlan plan, MachineTx tx); + + /** + * Adds {@code plan} back to the slot's plan without checking that it is legal, for undoing a + * {@link #release}. + * + * @throws IllegalStateException if the plans cannot be merged + */ + void restore(int slot, SlotPlan plan, MachineTx tx); + + /** Takes {@code plan}, which must have been reserved, out of the slot's plan. */ + void release(int slot, SlotPlan plan, MachineTx tx); + /** Whether all of {@code stacks} would fit together. Works on copies; changes nothing. */ default boolean fits(List stacks) { return fits(stacks, new long[size()]); @@ -30,5 +59,16 @@ default boolean fits(List stacks) { * Whether all of {@code stacks} would fit together once {@code taken[slot]} items are gone from * each slot. Works on copies; changes nothing. */ - boolean fits(List stacks, long[] taken); + default boolean fits(List stacks, long[] taken) { + return place(stacks, taken) != null; + } + + /** + * Where {@code stacks} would land once {@code taken[slot]} items are gone from each slot: per + * stack, per slot, how many go there. Room is measured against each slot's end state, so planned + * takes make room and planned adds use it, and a slot whose plan is not legal takes nothing. Null + * if they do not all fit. Works on copies; changes nothing. + */ + @Nullable + int[][] place(List stacks, long[] taken); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java index c290aef..9401380 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java @@ -3,9 +3,10 @@ import java.util.ArrayList; import java.util.List; +import org.jetbrains.annotations.Nullable; + import net.minecraft.world.item.ItemStack; -import net.neoforged.neoforge.fluids.FluidStack; import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; @@ -16,12 +17,16 @@ import zone.moddev.mc.lib.machine.inventory.FluidGroup; import zone.moddev.mc.lib.machine.inventory.ItemGroup; import zone.moddev.mc.lib.machine.inventory.MachineTx; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; +import zone.moddev.mc.lib.machine.inventory.TankPlan; /** * Machine groups over MMDLib's storages. They work as the block entity does: items through {@link * ItemStorage#insertResult} and the storage's own extract (so slot roles and filters don't apply), * fluids through {@link FluidStorage#tank}, energy ignoring the buffer's transfer rates but not its - * capacity. Transactions copy a slot, tank or buffer the first time they change it. + * capacity. Storages keep their plans, so extract and insert stop short of what is reserved. + * Transactions copy a slot, tank or buffer the first time they change it, and a slot's or tank's + * plan separately from its contents. */ public final class StorageGroups { private StorageGroups() {} @@ -49,9 +54,12 @@ public static EnergyGroup energy(EnergyBuffer buffer) { return new EnergyStore(buffer); } - /** A transaction key for one slot or tank of one storage; storages compare by identity. */ + /** A transaction key for the contents of one slot or tank; storages compare by identity. */ private record Key(Object storage, int index) {} + /** A transaction key for the plan of one slot or tank, kept apart from its contents. */ + private record PlanKey(Object storage, int index) {} + private record ItemSlots(ItemStorage storage, int[] slots) implements ItemGroup { @Override public int size() { @@ -63,6 +71,11 @@ public ItemStack peek(int slot) { return valid(slot) ? storage.getStackInSlot(slots[slot]).copy() : ItemStack.EMPTY; } + @Override + public int free(int slot) { + return valid(slot) ? storage.freeToTake(slots[slot]) : 0; + } + @Override public int extract(int slot, int count, MachineTx tx) { if (!valid(slot) || count <= 0) return 0; @@ -76,42 +89,78 @@ public int extract(int slot, int count, MachineTx tx) { public int insert(ItemStack stack, MachineTx tx) { var remaining = stack.copy(); for (boolean merging : new boolean[] {true, false}) - for (int index : slots) { + for (int slot = 0; slot < slots.length; slot++) { if (remaining.isEmpty()) break; - var held = storage.getStackInSlot(index); - if (merging == held.isEmpty()) continue; - int fits = remaining.getCount() - storage.insertResult(index, remaining, true).getCount(); - if (fits <= 0) continue; - track(index, tx); - remaining = storage.insertResult(index, remaining, false); + if (merging == storage.getStackInSlot(slots[slot]).isEmpty()) continue; + remaining.shrink(insert(slot, remaining.copy(), tx)); } return stack.getCount() - remaining.getCount(); } @Override - public boolean fits(List stacks, long[] taken) { + public int insert(int slot, ItemStack stack, MachineTx tx) { + if (!valid(slot) || stack.isEmpty()) return 0; + int index = slots[slot]; + if (storage.insertResult(index, stack, true).getCount() == stack.getCount()) return 0; + track(index, tx); + return stack.getCount() - storage.insertResult(index, stack.copy(), false).getCount(); + } + + @Override + public boolean reserve(int slot, SlotPlan plan, MachineTx tx) { + if (!valid(slot)) return false; + trackPlan(slots[slot], tx); + return storage.reserve(slots[slot], plan); + } + + @Override + public void restore(int slot, SlotPlan plan, MachineTx tx) { + if (!valid(slot)) return; + var merged = storage.plan(slots[slot]).plus(plan); + if (merged == null) throw new IllegalStateException("Plans cannot be merged in slot " + slot); + trackPlan(slots[slot], tx); + storage.setPlan(slots[slot], merged); + } + + @Override + public void release(int slot, SlotPlan plan, MachineTx tx) { + if (!valid(slot)) return; + trackPlan(slots[slot], tx); + storage.release(slots[slot], plan); + } + + @Override + @Nullable + public int[][] place(List stacks, long[] taken) { var held = new ArrayList(); + var usable = new boolean[slots.length]; for (int i = 0; i < slots.length; i++) { var stack = storage.getStackInSlot(slots[i]); - held.add(stack.copyWithCount(stack.getCount() - (int) taken[i])); + var plan = storage.plan(slots[i]); + var asked = new SlotPlan(plan.take() + (int) taken[i], plan.add()); + usable[i] = asked.legal(stack, storage.getSlotLimit(slots[i])); + held.add(usable[i] ? asked.endState(stack) : ItemStack.EMPTY); } - for (var stack : stacks) { + var placed = new int[stacks.size()][slots.length]; + for (int n = 0; n < stacks.size(); n++) { + var stack = stacks.get(n); int left = stack.getCount(); for (boolean merging : new boolean[] {true, false}) for (int i = 0; i < slots.length && left > 0; i++) { var slot = held.get(i); - if (merging == slot.isEmpty()) continue; + if (!usable[i] || merging == slot.isEmpty()) continue; if (merging && !ItemStack.isSameItemSameComponents(slot, stack)) continue; int limit = Math.min(storage.getSlotLimit(slots[i]), stack.getMaxStackSize()); int moved = Math.min(left, limit - slot.getCount()); if (moved <= 0) continue; if (slot.isEmpty()) held.set(i, stack.copyWithCount(moved)); else slot.grow(moved); + placed[n][i] = moved; left -= moved; } - if (left > 0) return false; + if (left > 0) return null; } - return true; + return placed; } private boolean valid(int slot) { @@ -126,6 +175,15 @@ private void track(int index, MachineTx tx) { return () -> storage.setStackInSlot(index, before); }); } + + private void trackPlan(int index, MachineTx tx) { + tx.track( + new PlanKey(storage, index), + () -> { + var before = storage.plan(index); + return () -> storage.setPlan(index, before); + }); + } } private record FluidTanks(FluidStorage storage, int[] indexes) implements FluidGroup { @@ -141,6 +199,11 @@ public FluidAmount peek(int tank) { : FluidAmount.EMPTY; } + @Override + public int free(int tank) { + return valid(tank) ? storage.plan(indexes[tank]).free(peek(tank)) : 0; + } + @Override public int extract(int tank, int amount, MachineTx tx) { if (!valid(tank) || amount <= 0) return 0; @@ -153,45 +216,83 @@ public int extract(int tank, int amount, MachineTx tx) { @Override public int insert(FluidAmount fluid, MachineTx tx) { if (fluid.isEmpty()) return 0; - var remaining = FluidStacks.stack(fluid); + var remaining = fluid; for (boolean merging : new boolean[] {true, false}) - for (int index : indexes) { - if (remaining.isEmpty()) break; - var handler = storage.tank(index); - if (merging == handler.isEmpty()) continue; - if (handler.fill(remaining, FluidAction.SIMULATE) <= 0) continue; - track(index, tx); - remaining.shrink(handler.fill(remaining, FluidAction.EXECUTE)); + for (int tank = 0; tank < indexes.length; tank++) { + if (remaining.amount() == 0) break; + if (merging == storage.tank(indexes[tank]).isEmpty()) continue; + remaining = remaining.withAmount(remaining.amount() - insert(tank, remaining, tx)); } - return fluid.amount() - remaining.getAmount(); + return fluid.amount() - remaining.amount(); + } + + @Override + public int insert(int tank, FluidAmount fluid, MachineTx tx) { + if (!valid(tank) || fluid.isEmpty()) return 0; + var handler = storage.tank(indexes[tank]); + var stack = FluidStacks.stack(fluid); + if (handler.fill(stack, FluidAction.SIMULATE) <= 0) return 0; + track(indexes[tank], tx); + return handler.fill(stack, FluidAction.EXECUTE); + } + + @Override + public boolean reserve(int tank, TankPlan plan, MachineTx tx) { + if (!valid(tank)) return false; + trackPlan(indexes[tank], tx); + return storage.reserve(indexes[tank], plan); } @Override - public boolean fits(List fluids, long[] taken) { - var held = new ArrayList(); + public void restore(int tank, TankPlan plan, MachineTx tx) { + if (!valid(tank)) return; + var merged = storage.plan(indexes[tank]).plus(plan); + if (merged == null) throw new IllegalStateException("Plans cannot be merged in tank " + tank); + trackPlan(indexes[tank], tx); + storage.setPlan(indexes[tank], merged); + } + + @Override + public void release(int tank, TankPlan plan, MachineTx tx) { + if (!valid(tank)) return; + trackPlan(indexes[tank], tx); + storage.release(indexes[tank], plan); + } + + @Override + @Nullable + public int[][] place(List fluids, long[] taken) { + var held = new ArrayList(); + var usable = new boolean[indexes.length]; for (int i = 0; i < indexes.length; i++) { - var fluid = storage.tank(indexes[i]).getFluid(); - held.add(fluid.copyWithAmount(fluid.getAmount() - (int) taken[i])); + var tank = storage.tank(indexes[i]); + var amount = FluidStacks.amount(tank.getFluid()); + var plan = storage.plan(indexes[i]); + var asked = new TankPlan(plan.take() + (int) taken[i], plan.add()); + usable[i] = asked.legal(amount, tank.getCapacity()); + held.add(usable[i] ? asked.endState(amount) : FluidAmount.EMPTY); } - for (var fluid : fluids) { + var placed = new int[fluids.size()][indexes.length]; + for (int n = 0; n < fluids.size(); n++) { + var fluid = fluids.get(n); if (fluid.isEmpty()) continue; var stack = FluidStacks.stack(fluid); int left = fluid.amount(); for (boolean merging : new boolean[] {true, false}) for (int i = 0; i < indexes.length && left > 0; i++) { var tank = held.get(i); - if (merging == tank.isEmpty()) continue; - if (merging && !FluidStack.isSameFluidSameComponents(tank, stack)) continue; + if (!usable[i] || merging == tank.isEmpty()) continue; + if (merging && !tank.sameFluid(fluid)) continue; if (!merging && !storage.tank(indexes[i]).isFluidValid(stack)) continue; - int moved = Math.min(left, storage.tank(indexes[i]).getCapacity() - tank.getAmount()); + int moved = Math.min(left, storage.tank(indexes[i]).getCapacity() - tank.amount()); if (moved <= 0) continue; - if (tank.isEmpty()) held.set(i, stack.copyWithAmount(moved)); - else tank.grow(moved); + held.set(i, fluid.withAmount(tank.amount() + moved)); + placed[n][i] = moved; left -= moved; } - if (left > 0) return false; + if (left > 0) return null; } - return true; + return placed; } private boolean valid(int tank) { @@ -206,6 +307,15 @@ private void track(int index, MachineTx tx) { return () -> storage.tank(index).setFluid(before); }); } + + private void trackPlan(int index, MachineTx tx) { + tx.track( + new PlanKey(storage, index), + () -> { + var before = storage.plan(index); + return () -> storage.setPlan(index, before); + }); + } } private record EnergyStore(EnergyBuffer buffer) implements EnergyGroup { From f47aee3ccd49e3d3c500b77b04f8305226112f52 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 05:34:21 +0200 Subject: [PATCH 06/72] feat(machine): matching and groups respect reservations Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/machine/recipe/RecipeMatcher.java | 6 +- .../moddev/mc/lib/smoke/MMDLibGameTests.java | 6 + .../mc/lib/smoke/MachineRecipeSmoke.java | 4 +- .../mc/lib/smoke/ReservedGroupSmoke.java | 258 ++++++++++++++++++ 4 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedGroupSmoke.java diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java index 0a22287..13e70f5 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java @@ -162,18 +162,18 @@ private static MatchResult checkOutputs( return MatchResult.OK; } - /** What each slot or tank of {@code group} holds, or the stored energy as one entry. */ + /** What each slot or tank of {@code group} has free, or the stored energy as one entry. */ private static long[] available(SlotGroup group) { return switch (group) { case ItemGroup items -> { var counts = new long[items.size()]; - for (int slot = 0; slot < counts.length; slot++) counts[slot] = items.peek(slot).getCount(); + for (int slot = 0; slot < counts.length; slot++) counts[slot] = items.free(slot); yield counts; } case FluidGroup fluids -> { var amounts = new long[fluids.tanks()]; for (int tank = 0; tank < amounts.length; tank++) - amounts[tank] = fluids.peek(tank).amount(); + amounts[tank] = fluids.free(tank); yield amounts; } case EnergyGroup energy -> new long[] {energy.stored()}; diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index b7468d2..782a000 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -106,6 +106,12 @@ public static void reservedStorage(GameTestHelper helper) { helper.succeed(); } + @GameTest(template = EMPTY) + public static void reservedGroups(GameTestHelper helper) { + ReservedGroupSmoke.check(helper.getLevel()); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void machineRecipes(GameTestHelper helper) { MachineRecipeSmoke.check(helper.getLevel()); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java index 75b0dce..76bba33 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java @@ -26,7 +26,7 @@ /** Checks recipe matching, consuming and producing with the crusher recipe from recipes.md. */ final class MachineRecipeSmoke { - private static final String CRUSHER = + static final String CRUSHER = """ { "inputs": { @@ -425,7 +425,7 @@ private static void consumedInputsMakeRoom(RegistryOps ops, String json) { + static MachineRecipe recipe(RegistryOps ops, String json) { return MachineRecipe.CODEC.codec().parse(ops, JsonParser.parseString(json)).getOrThrow(); } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedGroupSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedGroupSmoke.java new file mode 100644 index 0000000..4aecba4 --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedGroupSmoke.java @@ -0,0 +1,258 @@ +package zone.moddev.mc.lib.smoke; + +import java.util.List; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.resources.RegistryOps; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.levelgen.LegacyRandomSource; +import net.minecraft.world.level.material.Fluids; + +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; + +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; +import zone.moddev.mc.lib.machine.inventory.FluidGroup; +import zone.moddev.mc.lib.machine.inventory.ItemGroup; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; +import zone.moddev.mc.lib.machine.inventory.TankPlan; +import zone.moddev.mc.lib.machine.neoforge.FluidStacks; +import zone.moddev.mc.lib.machine.neoforge.StorageGroups; +import zone.moddev.mc.lib.machine.recipe.MatchResult; +import zone.moddev.mc.lib.machine.recipe.MatchResult.Reason; +import zone.moddev.mc.lib.machine.recipe.RecipeMatcher; + +/** Checks that groups and the matcher see reservation plans, with the crusher recipe. */ +final class ReservedGroupSmoke { + private ReservedGroupSmoke() {} + + /** A crusher's storages: ore in slot 0, output in 1 and 2, byproduct in 3. */ + private static final class Crusher { + final int[] changes = new int[1]; + final ItemStorage items = + new ItemStorage(4) + .onChange(() -> changes[0]++) + .role(Role.INPUT, 0) + .role(Role.OUTPUT, 1, 2, 3); + final FluidStorage fluids = new FluidStorage().onChange(() -> changes[0]++); + final EnergyBuffer energy = + new EnergyBuffer(10_000_000_000L, 1_000).onChange(() -> changes[0]++); + final MachineInventory inventory; + + Crusher(int ore, int water) { + fluids.addTank(4_000, Role.INPUT); + items.insertResult(0, new ItemStack(Items.COPPER_ORE, ore), false); + fluids.tank(0).fill(new FluidStack(Fluids.WATER, water), FluidAction.EXECUTE); + energy.setStored(20); + inventory = + MachineInventory.builder() + .group("input", StorageGroups.items(items, 0)) + .group("output", StorageGroups.items(items, 1, 2)) + .group("byproduct", StorageGroups.items(items, 3)) + .group("water", StorageGroups.fluids(fluids, 0)) + .group("energy", StorageGroups.energy(energy)) + .build(); + } + + ItemGroup items(String key) { + return ReservedGroupSmoke.items(inventory, key); + } + + FluidGroup water() { + return (FluidGroup) inventory.group("water").orElseThrow(); + } + } + + static void check(ServerLevel level) { + var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()); + var crusher = MachineRecipeSmoke.recipe(ops, MachineRecipeSmoke.CRUSHER); + var none = ItemStack.EMPTY; + var water = FluidStacks.amount(new FluidStack(Fluids.WATER, 1_000)); + + // Two lanes, inputs: what another plan takes is not there for this recipe. + var lanes = new Crusher(1, 1_000); + try (var tx = lanes.inventory.begin()) { + require(lanes.items("input").reserve(0, new SlotPlan(1, none), tx), "reserve the ore"); + tx.commit(); + } + expect(RecipeMatcher.check(crusher, lanes.inventory), Reason.MISSING_INPUT, "input"); + require(!RecipeMatcher.consume(crusher, lanes.inventory), "reserved ore is not consumed"); + require(lanes.items.getStackInSlot(0).getCount() == 1, "the reserved ore stays"); + var twoOres = new Crusher(2, 1_000); + try (var tx = twoOres.inventory.begin()) { + twoOres.items("input").reserve(0, new SlotPlan(1, none), tx); + tx.commit(); + } + require(RecipeMatcher.check(crusher, twoOres.inventory).ok(), "the second ore is free"); + var thirsty = new Crusher(3, 250); + try (var tx = thirsty.inventory.begin()) { + thirsty.water().reserve(0, new TankPlan(250, FluidAmount.EMPTY), tx); + tx.commit(); + } + expect(RecipeMatcher.check(crusher, thirsty.inventory), Reason.MISSING_INPUT, "water"); + + // Two lanes, space: 63 copper are planned into the only empty output slot. + var cramped = new Crusher(3, 1_000); + cramped.items.setStackInSlot(1, new ItemStack(Items.COBBLESTONE, 64)); + require(RecipeMatcher.check(crusher, cramped.inventory).ok(), "room before the plan"); + try (var tx = cramped.inventory.begin()) { + var copper = new ItemStack(Items.RAW_COPPER, 63); + require(cramped.items("output").reserve(1, new SlotPlan(0, copper), tx), "reserve space"); + tx.commit(); + } + expect(RecipeMatcher.check(crusher, cramped.inventory), Reason.NO_ROOM, "output"); + + // Room comes from the end state: iron that is planned away is not there to be taken twice. + var ironToGold = + MachineRecipeSmoke.recipe( + ops, + """ + {"inputs": {"main": [{"items": {"item": "minecraft:iron_ingot"}}]}, + "outputs": {"main": [{"item": {"id": "minecraft:gold_ingot"}}]}, "time": 10} + """); + var slot = new ItemStorage(1); + var oneSlot = MachineInventory.builder().group("main", StorageGroups.items(slot, 0)).build(); + slot.setStackInSlot(0, iron(1)); + require(RecipeMatcher.check(ironToGold, oneSlot).ok(), "iron to gold without a plan"); + try (var tx = oneSlot.begin()) { + require(items(oneSlot, "main").reserve(0, new SlotPlan(1, none), tx), "reserve the iron"); + tx.commit(); + } + expect(RecipeMatcher.check(ironToGold, oneSlot), Reason.MISSING_INPUT, "main"); + + // fits agrees with insertResult when the plan fills the slot. + var topped = new ItemStorage(1); + topped.setStackInSlot(0, iron(63)); + topped.setPlan(0, new SlotPlan(1, iron(2))); + var toppedGroup = StorageGroups.items(topped, 0); + require(!toppedGroup.fits(List.of(iron(1))), "fits refuses the last item"); + require(!topped.fits(0, iron(1)), "as insertResult does"); + + // place reports the split, and null when it cannot all go. + var pair = new ItemStorage(2); + pair.setStackInSlot(0, iron(24)); + pair.setStackInSlot(1, iron(40)); + var group = StorageGroups.items(pair, 0, 1); + var split = group.place(List.of(iron(64)), new long[2]); + require(split != null && split[0][0] == 40 && split[0][1] == 24, "40 and 24"); + require(group.place(List.of(iron(65)), new long[2]) == null, "65 do not fit"); + require(group.place(List.of(iron(40), iron(24)), new long[2]) != null, "stacks add up"); + var freed = group.place(List.of(iron(88)), new long[] {24, 0}); + require(freed != null && freed[0][0] == 64 && freed[0][1] == 24, "taken items leave room"); + pair.setPlan(0, new SlotPlan(30, none)); + var skipped = group.place(List.of(iron(10)), new long[2]); + require(skipped != null && skipped[0][0] == 0 && skipped[0][1] == 10, "skips an illegal plan"); + require(group.free(0) == 0 && group.free(1) == 40 && group.free(2) == 0, "free"); + + var tanks = new FluidStorage(); + tanks.addTank(1_000, Role.STORAGE); + tanks.addTank(1_000, Role.STORAGE); + tanks.tank(0).setFluid(new FluidStack(Fluids.WATER, 400)); + var tankGroup = StorageGroups.fluids(tanks, 0, 1); + var fluidSplit = tankGroup.place(List.of(water.withAmount(1_200)), new long[2]); + require(fluidSplit != null && fluidSplit[0][0] == 600 && fluidSplit[0][1] == 600, "mB split"); + require(tankGroup.place(List.of(water.withAmount(1_700)), new long[2]) == null, "too much"); + tanks.setPlan(0, new TankPlan(0, water.withAmount(300))); + var reservedSplit = tankGroup.place(List.of(water.withAmount(1_000)), new long[2]); + require( + reservedSplit != null && reservedSplit[0][0] == 300 && reservedSplit[0][1] == 700, + "tank plans leave less room"); + tanks.setPlan(0, new TankPlan(100, FluidAmount.EMPTY)); + require(tankGroup.free(0) == 300 && tankGroup.free(1) == 0, "free mB"); + + // Undo: a plan is undone with the transaction, and contents and plan restore together. + var undo = new Crusher(5, 1_000); + try (var tx = undo.inventory.begin()) { + require(undo.items("input").extract(0, 2, tx) == 2, "extract"); + require(undo.items("input").reserve(0, new SlotPlan(1, none), tx), "reserve"); + } + require(undo.items.plan(0).isNone(), "plan undone"); + require(undo.items.getStackInSlot(0).getCount() == 5, "contents undone"); + try (var tx = undo.inventory.begin()) { + undo.items("input").reserve(0, new SlotPlan(1, none), tx); + tx.commit(); + } + require(undo.items.plan(0).sameAs(new SlotPlan(1, none)), "committed plan stays"); + try (var tx = undo.inventory.begin()) { + var input = undo.items("input"); + input.release(0, new SlotPlan(1, none), tx); + require(undo.items.plan(0).isNone(), "released"); + input.restore(0, new SlotPlan(2, none), tx); + require(undo.items.plan(0).sameAs(new SlotPlan(2, none)), "restored past the check"); + } + require(undo.items.plan(0).sameAs(new SlotPlan(1, none)), "release and restore undone"); + + // Single-slot insert puts nothing anywhere else. + var target = new Crusher(0, 0); + try (var tx = target.inventory.begin()) { + var copper = new ItemStack(Items.RAW_COPPER, 3); + require(target.items("output").insert(1, copper, tx) == 3, "inserted"); + require(target.items("output").insert(5, copper, tx) == 0, "out of range"); + tx.commit(); + } + require(target.items.getStackInSlot(1).isEmpty(), "the other slot stays empty"); + require(target.items.getStackInSlot(2).getCount() == 3, "group slot 1 holds 3"); + + // consume and produce cannot touch what is reserved, and still work on the rest. + var work = new Crusher(3, 1_000); + try (var tx = work.inventory.begin()) { + work.items("input").reserve(0, new SlotPlan(1, none), tx); + work.items("output").reserve(0, new SlotPlan(0, new ItemStack(Items.GOLD_INGOT)), tx); + tx.commit(); + } + require(RecipeMatcher.consume(crusher, work.inventory), "consume the free ore"); + require(RecipeMatcher.consume(crusher, work.inventory), "and the next"); + require(!RecipeMatcher.consume(crusher, work.inventory), "but not the reserved one"); + require(work.items.getStackInSlot(0).getCount() == 1, "one ore left"); + require(RecipeMatcher.produce(crusher, work.inventory, missing()), "produce"); + require(work.items.getStackInSlot(1).isEmpty(), "the reserved slot got nothing"); + require(work.items.getStackInSlot(2).getCount() == 2, "the free slot got the copper"); + + // Checking, placing and counting free never run a change callback. + var quiet = new Crusher(3, 1_000); + int before = quiet.changes[0]; + RecipeMatcher.check(crusher, quiet.inventory); + quiet.items("output").place(List.of(new ItemStack(Items.RAW_COPPER, 2)), new long[2]); + quiet.items("output").free(0); + quiet.water().place(List.of(water.withAmount(100)), new long[1]); + quiet.water().free(0); + require(quiet.changes[0] == before, "no change callbacks"); + } + + private static ItemGroup items(MachineInventory inventory, String key) { + return (ItemGroup) inventory.group(key).orElseThrow(); + } + + /** A random that misses every chance. */ + private static RandomSource missing() { + return new LegacyRandomSource(0) { + @Override + public float nextFloat() { + return .99F; + } + }; + } + + private static ItemStack iron(int count) { + return new ItemStack(Items.IRON_INGOT, count); + } + + private static void expect(MatchResult result, Reason reason, String group) { + require( + result.reason() == reason && group.equals(result.group()), + "expected " + reason + " " + group + ", got " + result); + } + + private static void require(boolean condition, String what) { + if (!condition) throw new AssertionError("Reserved group smoke failed: " + what); + } +} From 7d99181b5cb7cc4c27ada5154bc478630b10c52c Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 05:34:56 +0200 Subject: [PATCH 07/72] style(machine): join the free-amount loop onto one line Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java index 13e70f5..ff97c31 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java @@ -172,8 +172,7 @@ private static long[] available(SlotGroup group) { } case FluidGroup fluids -> { var amounts = new long[fluids.tanks()]; - for (int tank = 0; tank < amounts.length; tank++) - amounts[tank] = fluids.free(tank); + for (int tank = 0; tank < amounts.length; tank++) amounts[tank] = fluids.free(tank); yield amounts; } case EnergyGroup energy -> new long[] {energy.stored()}; From f702b12f23cefe326201f25218f2af683cca4ef3 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 05:45:39 +0200 Subject: [PATCH 08/72] fix(machine): place leaves room for pending takes, restore reports refusal place caps each slot and tank by what is physically there once the recipe's own take has run, and keeps a different item or fluid out until then. restore returns false instead of throwing or ignoring, and is documented as the loading path. Skip and bypass tests now fail without the code they cover. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/machine/inventory/FluidGroup.java | 15 ++--- .../mc/lib/machine/inventory/ItemGroup.java | 16 ++--- .../lib/machine/neoforge/StorageGroups.java | 37 ++++++------ .../mc/lib/smoke/ReservedGroupSmoke.java | 60 ++++++++++++++++++- 4 files changed, 93 insertions(+), 35 deletions(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java index f1f3413..5e59069 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java @@ -40,12 +40,11 @@ public non-sealed interface FluidGroup extends SlotGroup { boolean reserve(int tank, TankPlan plan, MachineTx tx); /** - * Adds {@code plan} back to the tank's plan without checking that it is legal, for undoing a - * {@link #release}. - * - * @throws IllegalStateException if the plans cannot be merged + * Adds {@code plan} to the tank's plan without checking that it is legal, for loading plans that + * were saved while legal. Returns false and changes nothing if tank is outside the group or the + * plans cannot be merged. */ - void restore(int tank, TankPlan plan, MachineTx tx); + boolean restore(int tank, TankPlan plan, MachineTx tx); /** Takes {@code plan}, which must have been reserved, out of the tank's plan. */ void release(int tank, TankPlan plan, MachineTx tx); @@ -65,8 +64,10 @@ default boolean fits(List fluids, long[] taken) { /** * Where {@code fluids} would land once {@code taken[tank]} mB are gone from each tank: per fluid, - * per tank, how many mB go there. Room is measured against each tank's end state, and a tank - * whose plan is not legal takes nothing. Null if they do not all fit. Changes nothing. + * per tank, how many mB go there. A tank's end state decides what may join it and how much + * room planned takes and adds leave, but fluid another plan has yet to take still fills the tank + * now, and a different fluid waits until the tank has physically emptied. A tank whose plan is + * not legal takes nothing. Null if they do not all fit. Changes nothing. */ @Nullable int[][] place(List fluids, long[] taken); diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java index 66a8b2e..a978759 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java @@ -40,12 +40,11 @@ public non-sealed interface ItemGroup extends SlotGroup { boolean reserve(int slot, SlotPlan plan, MachineTx tx); /** - * Adds {@code plan} back to the slot's plan without checking that it is legal, for undoing a - * {@link #release}. - * - * @throws IllegalStateException if the plans cannot be merged + * Adds {@code plan} to the slot's plan without checking that it is legal, for loading plans that + * were saved while legal. Returns false and changes nothing if slot is outside the group or the + * plans cannot be merged. */ - void restore(int slot, SlotPlan plan, MachineTx tx); + boolean restore(int slot, SlotPlan plan, MachineTx tx); /** Takes {@code plan}, which must have been reserved, out of the slot's plan. */ void release(int slot, SlotPlan plan, MachineTx tx); @@ -65,9 +64,10 @@ default boolean fits(List stacks, long[] taken) { /** * Where {@code stacks} would land once {@code taken[slot]} items are gone from each slot: per - * stack, per slot, how many go there. Room is measured against each slot's end state, so planned - * takes make room and planned adds use it, and a slot whose plan is not legal takes nothing. Null - * if they do not all fit. Works on copies; changes nothing. + * stack, per slot, how many go there. A slot's end state decides what may join it and how much + * room planned takes and adds leave, but items another plan has yet to take still fill the slot + * now, and a different item waits until the slot has physically emptied. A slot whose plan is + * not legal takes nothing. Null if they do not all fit. Works on copies; changes nothing. */ @Nullable int[][] place(List stacks, long[] taken); diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java index 9401380..4f48e51 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java @@ -114,12 +114,13 @@ public boolean reserve(int slot, SlotPlan plan, MachineTx tx) { } @Override - public void restore(int slot, SlotPlan plan, MachineTx tx) { - if (!valid(slot)) return; + public boolean restore(int slot, SlotPlan plan, MachineTx tx) { + if (!valid(slot)) return false; var merged = storage.plan(slots[slot]).plus(plan); - if (merged == null) throw new IllegalStateException("Plans cannot be merged in slot " + slot); + if (merged == null) return false; trackPlan(slots[slot], tx); storage.setPlan(slots[slot], merged); + return true; } @Override @@ -139,7 +140,8 @@ public int[][] place(List stacks, long[] taken) { var plan = storage.plan(slots[i]); var asked = new SlotPlan(plan.take() + (int) taken[i], plan.add()); usable[i] = asked.legal(stack, storage.getSlotLimit(slots[i])); - held.add(usable[i] ? asked.endState(stack) : ItemStack.EMPTY); + int after = stack.getCount() - (int) taken[i]; + held.add(usable[i] ? stack.copyWithCount(after) : ItemStack.EMPTY); } var placed = new int[stacks.size()][slots.length]; for (int n = 0; n < stacks.size(); n++) { @@ -148,13 +150,11 @@ public int[][] place(List stacks, long[] taken) { for (boolean merging : new boolean[] {true, false}) for (int i = 0; i < slots.length && left > 0; i++) { var slot = held.get(i); - if (!usable[i] || merging == slot.isEmpty()) continue; - if (merging && !ItemStack.isSameItemSameComponents(slot, stack)) continue; - int limit = Math.min(storage.getSlotLimit(slots[i]), stack.getMaxStackSize()); - int moved = Math.min(left, limit - slot.getCount()); + var plan = storage.plan(slots[i]); + if (!usable[i] || merging == plan.endState(slot).isEmpty()) continue; + int moved = Math.min(left, plan.room(slot, stack, storage.getSlotLimit(slots[i]))); if (moved <= 0) continue; - if (slot.isEmpty()) held.set(i, stack.copyWithCount(moved)); - else slot.grow(moved); + held.set(i, stack.copyWithCount(slot.getCount() + moved)); placed[n][i] = moved; left -= moved; } @@ -244,12 +244,13 @@ public boolean reserve(int tank, TankPlan plan, MachineTx tx) { } @Override - public void restore(int tank, TankPlan plan, MachineTx tx) { - if (!valid(tank)) return; + public boolean restore(int tank, TankPlan plan, MachineTx tx) { + if (!valid(tank)) return false; var merged = storage.plan(indexes[tank]).plus(plan); - if (merged == null) throw new IllegalStateException("Plans cannot be merged in tank " + tank); + if (merged == null) return false; trackPlan(indexes[tank], tx); storage.setPlan(indexes[tank], merged); + return true; } @Override @@ -270,7 +271,8 @@ public int[][] place(List fluids, long[] taken) { var plan = storage.plan(indexes[i]); var asked = new TankPlan(plan.take() + (int) taken[i], plan.add()); usable[i] = asked.legal(amount, tank.getCapacity()); - held.add(usable[i] ? asked.endState(amount) : FluidAmount.EMPTY); + int after = amount.amount() - (int) taken[i]; + held.add(usable[i] ? amount.withAmount(after) : FluidAmount.EMPTY); } var placed = new int[fluids.size()][indexes.length]; for (int n = 0; n < fluids.size(); n++) { @@ -281,10 +283,11 @@ public int[][] place(List fluids, long[] taken) { for (boolean merging : new boolean[] {true, false}) for (int i = 0; i < indexes.length && left > 0; i++) { var tank = held.get(i); - if (!usable[i] || merging == tank.isEmpty()) continue; - if (merging && !tank.sameFluid(fluid)) continue; + var plan = storage.plan(indexes[i]); + if (!usable[i] || merging == plan.endState(tank).isEmpty()) continue; if (!merging && !storage.tank(indexes[i]).isFluidValid(stack)) continue; - int moved = Math.min(left, storage.tank(indexes[i]).getCapacity() - tank.amount()); + int moved = + Math.min(left, plan.room(tank, fluid, storage.tank(indexes[i]).getCapacity())); if (moved <= 0) continue; held.set(i, fluid.withAmount(tank.amount() + moved)); placed[n][i] = moved; diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedGroupSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedGroupSmoke.java index 4aecba4..630a194 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedGroupSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservedGroupSmoke.java @@ -150,7 +150,8 @@ static void check(ServerLevel level) { require(freed != null && freed[0][0] == 64 && freed[0][1] == 24, "taken items leave room"); pair.setPlan(0, new SlotPlan(30, none)); var skipped = group.place(List.of(iron(10)), new long[2]); - require(skipped != null && skipped[0][0] == 0 && skipped[0][1] == 10, "skips an illegal plan"); + require(skipped != null && skipped[0][0] == 0 && skipped[0][1] == 10, "uses the legal slot"); + require(group.place(List.of(iron(30)), new long[2]) == null, "skips an illegal plan"); require(group.free(0) == 0 && group.free(1) == 40 && group.free(2) == 0, "free"); var tanks = new FluidStorage(); @@ -169,6 +170,54 @@ static void check(ServerLevel level) { tanks.setPlan(0, new TankPlan(100, FluidAmount.EMPTY)); require(tankGroup.free(0) == 300 && tankGroup.free(1) == 0, "free mB"); + // A tank whose plan takes more than it holds is skipped; the other can't hold 500 alone. + var broken = new FluidStorage(); + broken.addTank(1_000, Role.STORAGE); + broken.addTank(1_000, Role.STORAGE); + broken.tank(0).setFluid(new FluidStack(Fluids.WATER, 400)); + broken.tank(1).setFluid(new FluidStack(Fluids.WATER, 600)); + broken.setPlan(0, new TankPlan(500, FluidAmount.EMPTY)); + var brokenGroup = StorageGroups.fluids(broken, 0, 1); + require(brokenGroup.place(List.of(water.withAmount(400)), new long[2]) != null, "400 fit"); + require(brokenGroup.place(List.of(water.withAmount(500)), new long[2]) == null, "tank skipped"); + + // Another plan's take makes room only once it has run: fits agrees with insertResult. + var owned = new ItemStorage(1); + owned.setStackInSlot(0, iron(64)); + owned.setPlan(0, new SlotPlan(30, none)); + var ownedGroup = StorageGroups.items(owned, 0); + require(!ownedGroup.fits(List.of(iron(10))), "a full slot has no room yet"); + require(!owned.fits(0, iron(10)), "as insertResult says"); + require(ownedGroup.fits(List.of(iron(10)), new long[] {30}), "own take makes room"); + var swap = new ItemStorage(1); + swap.setStackInSlot(0, iron(1)); + swap.setPlan(0, new SlotPlan(1, none)); + var swapGroup = StorageGroups.items(swap, 0); + var gold = new ItemStack(Items.GOLD_INGOT); + require(!swapGroup.fits(List.of(gold)), "gold waits for the iron to go"); + require(!swap.fits(0, gold), "as insertResult says"); + swap.setPlan(0, SlotPlan.NONE); + require(swapGroup.fits(List.of(gold), new long[] {1}), "gold fits once its own take ran"); + + var fullTank = new FluidStorage(); + fullTank.addTank(1_000, Role.STORAGE); + fullTank.tank(0).setFluid(new FluidStack(Fluids.WATER, 1_000)); + fullTank.setPlan(0, new TankPlan(300, FluidAmount.EMPTY)); + var fullGroup = StorageGroups.fluids(fullTank, 0); + require(!fullGroup.fits(List.of(water.withAmount(100))), "a full tank has no room yet"); + require(fullTank.fill(new FluidStack(Fluids.WATER, 100), FluidAction.SIMULATE) == 0, "fill"); + require(fullGroup.fits(List.of(water.withAmount(100)), new long[] {300}), "own take"); + var oneWater = new FluidStorage(); + oneWater.addTank(1_000, Role.STORAGE); + oneWater.tank(0).setFluid(new FluidStack(Fluids.WATER, 100)); + oneWater.setPlan(0, new TankPlan(100, FluidAmount.EMPTY)); + var lava = FluidStacks.amount(new FluidStack(Fluids.LAVA, 100)); + var oneWaterGroup = StorageGroups.fluids(oneWater, 0); + require(!oneWaterGroup.fits(List.of(lava)), "lava waits for the water to go"); + require(oneWater.fill(new FluidStack(Fluids.LAVA, 100), FluidAction.SIMULATE) == 0, "fill"); + oneWater.setPlan(0, TankPlan.NONE); + require(oneWaterGroup.fits(List.of(lava), new long[] {100}), "lava fits once it went"); + // Undo: a plan is undone with the transaction, and contents and plan restore together. var undo = new Crusher(5, 1_000); try (var tx = undo.inventory.begin()) { @@ -182,12 +231,17 @@ static void check(ServerLevel level) { tx.commit(); } require(undo.items.plan(0).sameAs(new SlotPlan(1, none)), "committed plan stays"); + try (var tx = undo.inventory.begin()) { var input = undo.items("input"); input.release(0, new SlotPlan(1, none), tx); require(undo.items.plan(0).isNone(), "released"); - input.restore(0, new SlotPlan(2, none), tx); - require(undo.items.plan(0).sameAs(new SlotPlan(2, none)), "restored past the check"); + var ore = new ItemStack(Items.COPPER_ORE); + require(input.restore(0, new SlotPlan(6, ore), tx), "restore"); + require(undo.items.plan(0).sameAs(new SlotPlan(6, ore)), "restored past the check"); + require(!input.restore(0, new SlotPlan(0, gold), tx), "other items cannot merge"); + require(!input.restore(7, new SlotPlan(1, none), tx), "out of range"); + require(undo.items.plan(0).sameAs(new SlotPlan(6, ore)), "refusals changed nothing"); } require(undo.items.plan(0).sameAs(new SlotPlan(1, none)), "release and restore undone"); From 6aff87903fc8b84fa7480c4dd84fc92d942af523 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 05:55:22 +0200 Subject: [PATCH 09/72] refactor(machine): share the matcher's claim and available with plans Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java index ff97c31..9b9a921 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java @@ -163,7 +163,7 @@ private static MatchResult checkOutputs( } /** What each slot or tank of {@code group} has free, or the stored energy as one entry. */ - private static long[] available(SlotGroup group) { + static long[] available(SlotGroup group) { return switch (group) { case ItemGroup items -> { var counts = new long[items.size()]; @@ -180,7 +180,7 @@ private static long[] available(SlotGroup group) { } /** Adds {@code ingredient} to {@code claims}; returns what's still needed, -1 if wrong kind. */ - private static long claim( + static long claim( InputClaims claims, SlotGroup group, MachineIngredient ingredient, int time) { return switch (ingredient) { case ItemPart part when group instanceof ItemGroup items -> From c9c817b0b50d0c227aaa0c61996f54c07f59baf2 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 05:55:22 +0200 Subject: [PATCH 10/72] feat(machine): a self-contained plan for one recipe cycle Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/machine/recipe/CyclePlan.java | 456 ++++++++++++++++++ .../moddev/mc/lib/smoke/CyclePlanSmoke.java | 295 +++++++++++ .../moddev/mc/lib/smoke/MMDLibGameTests.java | 6 + 3 files changed, 757 insertions(+) create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java new file mode 100644 index 0000000..3484677 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java @@ -0,0 +1,456 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.jetbrains.annotations.Nullable; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.RandomSource; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient.EnergyPart; +import zone.moddev.mc.lib.machine.inventory.EnergyGroup; +import zone.moddev.mc.lib.machine.inventory.FluidGroup; +import zone.moddev.mc.lib.machine.inventory.ItemGroup; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.inventory.MachineTx; +import zone.moddev.mc.lib.machine.inventory.SlotGroup; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; +import zone.moddev.mc.lib.machine.inventory.TankPlan; +import zone.moddev.mc.lib.machine.recipe.MachineProduct.FluidProduct; +import zone.moddev.mc.lib.machine.recipe.MachineProduct.ItemProduct; + +/** + * One running recipe cycle: what it takes from which slots and tanks, where its products will go, + * and the energy it needs. It holds everything it needs to finish, so a reload that changes or + * removes the recipe can't change a cycle that has begun. Groups are named by key and slots and + * tanks by index within the group, as in {@link RecipeMatcher}. + * + *

Reserving and restoring work from these fields alone, so a plan may be built by hand. + * + * @param recipe the recipe it was made from + * @param time ticks the cycle takes + * @param items item inputs, by slot + * @param fluids fluid inputs, by tank + * @param products each product with where it lands; chance products are placed as if certain + * @param energy the energy each energy group must supply over the cycle + */ +public record CyclePlan( + ResourceLocation recipe, + int time, + List items, + List fluids, + List products, + Map energy) { + /** The save form. */ + public static final Codec CODEC = + RecordCodecBuilder.create( + instance -> + instance + .group( + ResourceLocation.CODEC.fieldOf("recipe").forGetter(CyclePlan::recipe), + Codec.intRange(1, Integer.MAX_VALUE) + .fieldOf("time") + .forGetter(CyclePlan::time), + ItemTake.CODEC + .listOf() + .optionalFieldOf("items", List.of()) + .forGetter(CyclePlan::items), + FluidTake.CODEC + .listOf() + .optionalFieldOf("fluids", List.of()) + .forGetter(CyclePlan::fluids), + Placed.CODEC + .listOf() + .optionalFieldOf("products", List.of()) + .forGetter(CyclePlan::products), + Codec.unboundedMap(Codec.STRING, Codec.LONG) + .optionalFieldOf("energy", Map.of()) + .forGetter(CyclePlan::energy)) + .apply(instance, CyclePlan::new)); + + /** What the cycle takes from one item slot. */ + public record ItemTake(String group, int slot, ItemStack stack) { + static final Codec CODEC = + RecordCodecBuilder.create( + instance -> + instance + .group( + Codec.STRING.fieldOf("group").forGetter(ItemTake::group), + Codec.intRange(0, Integer.MAX_VALUE) + .fieldOf("slot") + .forGetter(ItemTake::slot), + ItemStack.CODEC.fieldOf("stack").forGetter(ItemTake::stack)) + .apply(instance, ItemTake::new)); + + /** Copies the stack. */ + public ItemTake { + stack = stack.copy(); + } + + /** A copy of the stack. */ + @Override + public ItemStack stack() { + return stack.copy(); + } + } + + /** What the cycle takes from one tank. */ + public record FluidTake(String group, int tank, FluidAmount fluid) { + static final Codec CODEC = + RecordCodecBuilder.create( + instance -> + instance + .group( + Codec.STRING.fieldOf("group").forGetter(FluidTake::group), + Codec.intRange(0, Integer.MAX_VALUE) + .fieldOf("tank") + .forGetter(FluidTake::tank), + FluidAmount.CODEC.fieldOf("fluid").forGetter(FluidTake::fluid)) + .apply(instance, FluidTake::new)); + } + + /** A product and where it goes: how much into each slot or tank of the group. */ + public record Placed(String group, MachineProduct product, List at) { + static final Codec CODEC = + RecordCodecBuilder.create( + instance -> + instance + .group( + Codec.STRING.fieldOf("group").forGetter(Placed::group), + MachineProduct.CODEC.fieldOf("product").forGetter(Placed::product), + At.CODEC.listOf().fieldOf("at").forGetter(Placed::at)) + .apply(instance, Placed::new)); + + /** Copies the list. */ + public Placed { + at = List.copyOf(at); + } + } + + /** {@code amount} items or mB into slot or tank {@code index} of a group. */ + public record At(int index, int amount) { + static final Codec CODEC = + RecordCodecBuilder.create( + instance -> + instance + .group( + Codec.intRange(0, Integer.MAX_VALUE) + .fieldOf("index") + .forGetter(At::index), + Codec.intRange(1, Integer.MAX_VALUE) + .fieldOf("amount") + .forGetter(At::amount)) + .apply(instance, At::new)); + } + + /** Copies the lists and the map. */ + public CyclePlan { + items = List.copyOf(items); + fluids = List.copyOf(fluids); + products = List.copyOf(products); + energy = Map.copyOf(energy); + } + + /** One slot or tank of a group. */ + private record Spot(String group, int index) {} + + /** This cycle's plan for each slot and tank it touches, items and fluids combined per spot. */ + private record Plans(Map items, Map tanks) {} + + /** + * What running {@code recipe} would reserve now, ignoring energy; empty if it can't. Changes + * nothing. Chance products are placed as if certain. + */ + public static Optional of( + ResourceLocation id, MachineRecipe recipe, MachineInventory inventory) { + var items = new ArrayList(); + var fluids = new ArrayList(); + var energy = new LinkedHashMap(); + var taken = new HashMap(); + for (var entry : recipe.inputs().entrySet()) { + var key = entry.getKey(); + var group = inventory.group(key).orElse(null); + if (group == null) return Optional.empty(); + if (group instanceof EnergyGroup) { + long total = 0; + for (var part : entry.getValue()) { + if (!(part instanceof EnergyPart energyPart)) return Optional.empty(); + total += energyPart.amount(); + } + energy.put(key, total); + continue; + } + var held = RecipeMatcher.available(group); + var claims = new InputClaims(held); + for (var ingredient : entry.getValue()) + if (RecipeMatcher.claim(claims, group, ingredient, recipe.time()) != 0) + return Optional.empty(); + var claimed = new long[held.length]; + for (int at = 0; at < held.length; at++) { + claimed[at] = claims.claimed(at); + int amount = (int) claimed[at]; + if (amount == 0) continue; + if (group instanceof ItemGroup slots) + items.add(new ItemTake(key, at, slots.peek(at).copyWithCount(amount))); + else if (group instanceof FluidGroup tanks) + fluids.add(new FluidTake(key, at, tanks.peek(at).withAmount(amount))); + } + taken.put(key, claimed); + } + var products = new ArrayList(); + for (var entry : recipe.outputs().entrySet()) { + var key = entry.getKey(); + var group = inventory.group(key).orElse(null); + if (group == null) return Optional.empty(); + var list = entry.getValue(); + var placed = place(group, list, taken.getOrDefault(key, new long[size(group)])); + if (placed == null) return Optional.empty(); + for (int i = 0; i < list.size(); i++) { + var at = new ArrayList(); + for (int slot = 0; slot < placed[i].length; slot++) + if (placed[i][slot] > 0) at.add(new At(slot, placed[i][slot])); + products.add(new Placed(key, list.get(i), at)); + } + } + return Optional.of(new CyclePlan(id, recipe.time(), items, fluids, products, energy)); + } + + /** + * Reserves the inputs and the space for the products, all or nothing: if any slot or tank + * refuses, nothing is reserved. Call it on the inventory {@link #of} looked at, before another + * cycle reserves, or it may be refused. + * + * @return whether everything was reserved + */ + public boolean reserve(MachineInventory inventory) { + var plans = plans(); + if (plans == null) return false; + try (var tx = inventory.begin()) { + for (var entry : plans.items.entrySet()) { + var spot = entry.getKey(); + var group = items(inventory, spot.group); + if (group == null || !group.reserve(spot.index, entry.getValue(), tx)) return false; + } + for (var entry : plans.tanks.entrySet()) { + var spot = entry.getKey(); + var group = fluids(inventory, spot.group); + if (group == null || !group.reserve(spot.index, entry.getValue(), tx)) return false; + } + tx.commit(); + return true; + } + } + + /** + * Puts this plan back on storages that already hold what it reserved, for loading a saved cycle. + * Nothing is checked beyond that the inputs are still there; the room was reserved when it was + * saved. All or nothing. + * + * @return false, with no plan left behind, if a group, slot or tank is gone or no longer holds + * the reserved inputs + */ + public boolean restore(MachineInventory inventory) { + var plans = plans(); + if (plans == null) return false; + for (var entry : plans.items.entrySet()) { + var spot = entry.getKey(); + var group = items(inventory, spot.group); + if (group == null || group.free(spot.index) < entry.getValue().take()) return false; + } + for (var entry : plans.tanks.entrySet()) { + var spot = entry.getKey(); + var group = fluids(inventory, spot.group); + if (group == null || group.free(spot.index) < entry.getValue().take()) return false; + } + for (var take : items) { + var group = items(inventory, take.group); + if (group == null) return false; + if (!ItemStack.isSameItemSameComponents(group.peek(take.slot), take.stack)) return false; + } + for (var take : fluids) { + var group = fluids(inventory, take.group); + if (group == null || !group.peek(take.tank).sameFluid(take.fluid)) return false; + } + try (var tx = inventory.begin()) { + for (var entry : plans.items.entrySet()) { + var spot = entry.getKey(); + if (!items(inventory, spot.group).restore(spot.index, entry.getValue(), tx)) return false; + } + for (var entry : plans.tanks.entrySet()) { + var spot = entry.getKey(); + if (!fluids(inventory, spot.group).restore(spot.index, entry.getValue(), tx)) return false; + } + tx.commit(); + return true; + } + } + + /** Gives back everything {@link #reserve} or {@link #restore} reserved. */ + public void release(MachineInventory inventory) { + try (var tx = inventory.begin()) { + release(inventory, tx); + tx.commit(); + } + } + + /** + * In one transaction: releases this plan, takes the inputs, rolls each product's chance and + * inserts the hits where they were placed. A product that misses leaves its space free, and + * nothing is kept of the roll, so a retry rolls again. + * + * @return false, with the plan still in place and nothing changed, if an input is gone or a + * product no longer fits where it was placed + */ + public boolean finish(MachineInventory inventory, RandomSource random) { + if (plans() == null) return false; + try (var tx = inventory.begin()) { + release(inventory, tx); + for (var take : items) { + var group = items(inventory, take.group); + if (group == null) return false; + var held = group.peek(take.slot); + int count = take.stack.getCount(); + if (!ItemStack.isSameItemSameComponents(held, take.stack) || held.getCount() < count) + return false; + if (group.extract(take.slot, count, tx) != count) return false; + } + for (var take : fluids) { + var group = fluids(inventory, take.group); + if (group == null) return false; + var held = group.peek(take.tank); + if (!held.sameFluid(take.fluid) || held.amount() < take.fluid.amount()) return false; + if (group.extract(take.tank, take.fluid.amount(), tx) != take.fluid.amount()) return false; + } + for (var placed : products) { + var product = placed.product; + if (product.chance() < 1 && random.nextFloat() >= product.chance()) continue; + for (var at : placed.at) + if (!insert(inventory, placed, at, tx)) return false; + } + tx.commit(); + return true; + } + } + + private boolean insert(MachineInventory inventory, Placed placed, At at, MachineTx tx) { + return switch (placed.product) { + case ItemProduct item -> { + var group = items(inventory, placed.group); + yield group != null + && group.insert(at.index, item.stack().copyWithCount(at.amount), tx) == at.amount; + } + case FluidProduct fluid -> { + var group = fluids(inventory, placed.group); + yield group != null + && group.insert(at.index, fluid.fluid().withAmount(at.amount), tx) == at.amount; + } + }; + } + + private void release(MachineInventory inventory, MachineTx tx) { + var plans = plans(); + if (plans == null) return; + plans.items.forEach( + (spot, plan) -> { + var group = items(inventory, spot.group); + if (group != null) group.release(spot.index, plan, tx); + }); + plans.tanks.forEach( + (spot, plan) -> { + var group = fluids(inventory, spot.group); + if (group != null) group.release(spot.index, plan, tx); + }); + } + + /** The takes and placements summed per slot and tank, or null if two of them clash. */ + @Nullable + private Plans plans() { + var itemPlans = new LinkedHashMap(); + var tankPlans = new LinkedHashMap(); + for (var take : items) { + var plan = new SlotPlan(take.stack.getCount(), ItemStack.EMPTY); + if (!add(itemPlans, new Spot(take.group, take.slot), plan)) return null; + } + for (var take : fluids) { + var plan = new TankPlan(take.fluid.amount(), FluidAmount.EMPTY); + if (!add(tankPlans, new Spot(take.group, take.tank), plan)) return null; + } + for (var placed : products) + for (var at : placed.at) { + var spot = new Spot(placed.group, at.index); + boolean merged = + switch (placed.product) { + case ItemProduct item -> + add(itemPlans, spot, new SlotPlan(0, item.stack().copyWithCount(at.amount))); + case FluidProduct fluid -> + add(tankPlans, spot, new TankPlan(0, fluid.fluid().withAmount(at.amount))); + }; + if (!merged) return null; + } + return new Plans(itemPlans, tankPlans); + } + + private static boolean add(Map plans, Spot spot, SlotPlan plan) { + var sum = plans.containsKey(spot) ? plans.get(spot).plus(plan) : plan; + if (sum == null) return false; + plans.put(spot, sum); + return true; + } + + private static boolean add(Map plans, Spot spot, TankPlan plan) { + var sum = plans.containsKey(spot) ? plans.get(spot).plus(plan) : plan; + if (sum == null) return false; + plans.put(spot, sum); + return true; + } + + /** Where {@code products} land in {@code group}; null if they don't fit or are the wrong kind. */ + @Nullable + private static int[][] place(SlotGroup group, List products, long[] taken) { + if (group instanceof ItemGroup slots) { + var stacks = new ArrayList(); + for (var product : products) { + if (!(product instanceof ItemProduct item)) return null; + stacks.add(item.stack()); + } + return slots.place(stacks, taken); + } + if (group instanceof FluidGroup tanks) { + var amounts = new ArrayList(); + for (var product : products) { + if (!(product instanceof FluidProduct fluid)) return null; + amounts.add(fluid.fluid()); + } + return tanks.place(amounts, taken); + } + return null; + } + + private static int size(SlotGroup group) { + return switch (group) { + case ItemGroup slots -> slots.size(); + case FluidGroup tanks -> tanks.tanks(); + case EnergyGroup energy -> 0; + }; + } + + @Nullable + private static ItemGroup items(MachineInventory inventory, String key) { + return inventory.group(key).orElse(null) instanceof ItemGroup group ? group : null; + } + + @Nullable + private static FluidGroup fluids(MachineInventory inventory, String key) { + return inventory.group(key).orElse(null) instanceof FluidGroup group ? group : null; + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java new file mode 100644 index 0000000..8fd4648 --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java @@ -0,0 +1,295 @@ +package zone.moddev.mc.lib.smoke; + +import java.util.List; +import java.util.Map; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.levelgen.LegacyRandomSource; +import net.minecraft.world.level.material.Fluids; + +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; + +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; +import zone.moddev.mc.lib.machine.neoforge.StorageGroups; +import zone.moddev.mc.lib.machine.recipe.CyclePlan; +import zone.moddev.mc.lib.machine.recipe.CyclePlan.At; +import zone.moddev.mc.lib.machine.recipe.CyclePlan.FluidTake; +import zone.moddev.mc.lib.machine.recipe.CyclePlan.ItemTake; +import zone.moddev.mc.lib.machine.recipe.CyclePlan.Placed; +import zone.moddev.mc.lib.machine.recipe.MachineProduct.ItemProduct; + +/** Checks one cycle's plan end to end with the crusher recipe: build, reserve, restore, finish. */ +final class CyclePlanSmoke { + private static final ResourceLocation ID = ResourceLocation.parse("example:crushing/copper"); + + private CyclePlanSmoke() {} + + /** A crusher's storages: ore in slot 0, output in 1 and 2, byproduct in 3. */ + private static final class Crusher { + final ItemStorage items = new ItemStorage(4).role(Role.INPUT, 0).role(Role.OUTPUT, 1, 2, 3); + final FluidStorage fluids = new FluidStorage(); + final EnergyBuffer energy = new EnergyBuffer(10_000_000_000L, 1_000); + final MachineInventory inventory; + + Crusher(int ore, int water) { + fluids.addTank(4_000, Role.INPUT); + items.insertResult(0, new ItemStack(Items.COPPER_ORE, ore), false); + fluids.tank(0).fill(new FluidStack(Fluids.WATER, water), FluidAction.EXECUTE); + inventory = + MachineInventory.builder() + .group("input", StorageGroups.items(items, 0)) + .group("output", StorageGroups.items(items, 1, 2)) + .group("byproduct", StorageGroups.items(items, 3)) + .group("water", StorageGroups.fluids(fluids, 0)) + .group("energy", StorageGroups.energy(energy)) + .build(); + } + + boolean unplanned() { + for (int slot = 0; slot < 4; slot++) if (!items.plan(slot).isNone()) return false; + return fluids.plan(0).isNone(); + } + } + + static void check(ServerLevel level) { + var access = level.registryAccess(); + var jsonOps = RegistryOps.create(JsonOps.INSTANCE, access); + var crusher = MachineRecipeSmoke.recipe(jsonOps, MachineRecipeSmoke.CRUSHER); + var none = ItemStack.EMPTY; + var rawCopper = new ItemStack(Items.RAW_COPPER, 2); + + // of: what the crusher would reserve, with no energy stored. + var start = new Crusher(3, 1_000); + var plan = CyclePlan.of(ID, crusher, start.inventory).orElseThrow(); + require(plan.recipe().equals(ID) && plan.time() == 200, "recipe and time"); + require(plan.items().size() == 1, "one item take"); + var ore = plan.items().getFirst(); + require( + ore.group().equals("input") + && ore.slot() == 0 + && ItemStack.matches(ore.stack(), new ItemStack(Items.COPPER_ORE)), + "takes 1 ore from slot 0"); + require(plan.fluids().size() == 1, "one fluid take"); + var water = plan.fluids().getFirst(); + require( + water.group().equals("water") && water.tank() == 0 && water.fluid().amount() == 250, + "takes 250 mB from tank 0"); + require(plan.products().size() == 2, "two products"); + var copper = plan.products().getFirst(); + require( + copper.group().equals("output") + && copper.product() instanceof ItemProduct item + && ItemStack.matches(item.stack(), rawCopper) + && copper.at().equals(List.of(new At(0, 2))), + "2 raw copper at group slot 0"); + require(plan.energy().equals(Map.of("energy", 4_000L)), "energy " + plan.energy()); + require(start.unplanned(), "of changes nothing"); + require( + CyclePlan.of(ID, crusher, new Crusher(0, 1_000).inventory).isEmpty(), "no ore, no plan"); + require( + CyclePlan.of(ID, crusher, new Crusher(3, 249).inventory).isEmpty(), "too little water"); + + // reserve: the ore is locked and the space is held; a second one that cannot fit is refused. + var held = new Crusher(3, 1_000); + var first = CyclePlan.of(ID, crusher, held.inventory).orElseThrow(); + require(first.reserve(held.inventory), "reserve"); + require(held.items.freeToTake(0) == 2, "the ore is locked"); + require(held.fluids.plan(0).take() == 250, "the water is locked"); + require(held.items.plan(1).sameAs(new SlotPlan(0, rawCopper)), "the copper space is held"); + require( + held.items.plan(3).sameAs(new SlotPlan(0, new ItemStack(Items.GRAVEL))), + "the gravel space is held"); + var cramped = new Crusher(3, 1_000); + cramped.items.setStackInSlot(1, new ItemStack(Items.RAW_COPPER, 62)); + cramped.items.setStackInSlot(2, new ItemStack(Items.COBBLESTONE, 64)); + var one = CyclePlan.of(ID, crusher, cramped.inventory).orElseThrow(); + var two = CyclePlan.of(ID, crusher, cramped.inventory).orElseThrow(); + require(one.reserve(cramped.inventory), "the first fits"); + require(!two.reserve(cramped.inventory), "the second would overfill the output"); + require( + cramped.items.plan(0).sameAs(new SlotPlan(1, none)) + && cramped.items.plan(1).sameAs(new SlotPlan(0, rawCopper)) + && cramped.fluids.plan(0).take() == 250, + "the first stays as it was"); + + // finish: inputs taken, products in, every plan gone. + var run = new Crusher(3, 1_000); + var cycle = CyclePlan.of(ID, crusher, run.inventory).orElseThrow(); + require(cycle.reserve(run.inventory), "reserve before finishing"); + require(cycle.finish(run.inventory, fixed(.99F)), "finish"); + require(run.items.getStackInSlot(0).getCount() == 2, "ore 3 to 2"); + require(run.fluids.tank(0).getFluidAmount() == 750, "water down 250"); + require(ItemStack.matches(run.items.getStackInSlot(1), rawCopper), "2 raw copper out"); + require(run.unplanned(), "every plan is gone"); + + // A hit puts the byproduct in; a miss leaves its space free and no plan behind. + var hit = new Crusher(3, 1_000); + var hitPlan = CyclePlan.of(ID, crusher, hit.inventory).orElseThrow(); + hitPlan.reserve(hit.inventory); + require(hitPlan.finish(hit.inventory, fixed(0F)), "finish on a hit"); + require(hit.items.getStackInSlot(3).is(Items.GRAVEL), "gravel on a hit"); + var miss = new Crusher(3, 1_000); + var missPlan = CyclePlan.of(ID, crusher, miss.inventory).orElseThrow(); + missPlan.reserve(miss.inventory); + require(missPlan.finish(miss.inventory, fixed(.5F)), "finish on a miss"); + require(miss.items.getStackInSlot(3).isEmpty(), "no gravel on a miss"); + require(miss.items.plan(3).isNone() && miss.unplanned(), "the byproduct slot is free"); + + // The one-slot machine: its input slot is also its output slot. + var ironToGold = + MachineRecipeSmoke.recipe( + jsonOps, + """ + {"inputs": {"main": [{"items": {"item": "minecraft:iron_ingot"}}]}, + "outputs": {"main": [{"item": {"id": "minecraft:gold_ingot"}}]}, "time": 10} + """); + var slot = new ItemStorage(1); + var oneSlot = MachineInventory.builder().group("main", StorageGroups.items(slot, 0)).build(); + slot.setStackInSlot(0, new ItemStack(Items.IRON_INGOT)); + var swap = CyclePlan.of(ID, ironToGold, oneSlot).orElseThrow(); + require(swap.reserve(oneSlot), "reserve iron to gold"); + require(slot.plan(0).sameAs(new SlotPlan(1, new ItemStack(Items.GOLD_INGOT))), "one plan"); + require(swap.finish(oneSlot, fixed(0F)), "finish iron to gold"); + require( + ItemStack.matches(slot.getStackInSlot(0), new ItemStack(Items.GOLD_INGOT)) + && slot.plan(0).isNone(), + "1 gold is left"); + + // A failed finish changes nothing: the plan stays and nothing is inserted. + var emptied = new Crusher(3, 1_000); + var emptiedPlan = CyclePlan.of(ID, crusher, emptied.inventory).orElseThrow(); + emptiedPlan.reserve(emptied.inventory); + emptied.items.setStackInSlot(0, none); + require(!emptiedPlan.finish(emptied.inventory, fixed(0F)), "no ore, no finish"); + require(emptied.fluids.tank(0).getFluidAmount() == 1_000, "water stays"); + require(emptied.items.getStackInSlot(1).isEmpty(), "nothing inserted"); + require( + emptied.items.plan(0).sameAs(new SlotPlan(1, none)) + && emptied.items.plan(1).sameAs(new SlotPlan(0, rawCopper)) + && emptied.fluids.plan(0).take() == 250, + "the plan stays in place"); + var dirt = new Crusher(3, 1_000); + var dirtPlan = CyclePlan.of(ID, crusher, dirt.inventory).orElseThrow(); + dirtPlan.reserve(dirt.inventory); + dirt.items.setStackInSlot(0, new ItemStack(Items.DIRT, 3)); + require(!dirtPlan.finish(dirt.inventory, fixed(0F)), "dirt is not ore"); + require( + ItemStack.matches(dirt.items.getStackInSlot(0), new ItemStack(Items.DIRT, 3)), + "the dirt is untouched"); + require(dirt.items.getStackInSlot(1).isEmpty(), "nothing inserted"); + require(dirt.items.plan(0).sameAs(new SlotPlan(1, none)), "the plan stays on the slot"); + + // release: this plan goes, another lane's plan on the same slot stays. + var shared = new Crusher(3, 1_000); + var other = new SlotPlan(1, none); + shared.items.setPlan(0, other); + var mine = CyclePlan.of(ID, crusher, shared.inventory).orElseThrow(); + mine.reserve(shared.inventory); + mine.release(shared.inventory); + require(shared.items.plan(0).sameAs(other), "the other plan stays"); + require(shared.items.plan(1).isNone() && shared.items.plan(3).isNone(), "outputs released"); + require(shared.fluids.plan(0).isNone(), "tank released"); + var alone = new Crusher(3, 1_000); + var lone = CyclePlan.of(ID, crusher, alone.inventory).orElseThrow(); + lone.reserve(alone.inventory); + lone.release(alone.inventory); + require(alone.unplanned(), "all plans are NONE"); + + // Round trip: the same plan comes back from NBT and restores the same reservations. + var nbt = access.createSerializationContext(NbtOps.INSTANCE); + var saved = new Crusher(3, 1_000); + var savedPlan = CyclePlan.of(ID, crusher, saved.inventory).orElseThrow(); + savedPlan.reserve(saved.inventory); + var tag = (CompoundTag) CyclePlan.CODEC.encodeStart(nbt, savedPlan).getOrThrow(); + var loaded = CyclePlan.CODEC.parse(nbt, tag).getOrThrow(); + require( + loaded.recipe().equals(ID) + && loaded.time() == 200 + && loaded.energy().equals(savedPlan.energy()) + && loaded.products().size() == 2, + "decoded plan"); + var fresh = new Crusher(3, 1_000); + require(loaded.restore(fresh.inventory), "restore on fresh storages"); + for (int index = 0; index < 4; index++) + require(fresh.items.plan(index).sameAs(saved.items.plan(index)), "plan " + index); + require(fresh.fluids.plan(0).sameAs(saved.fluids.plan(0)), "tank plan"); + + // restore refuses what no longer holds, and leaves no plan behind. + var oreTake = new ItemStack(Items.COPPER_ORE); + var cases = + Map.of( + "slot past the end", withItems(savedPlan, List.of(new ItemTake("input", 1, oreTake))), + "missing group", withItems(savedPlan, List.of(new ItemTake("nothing", 0, oreTake))), + "fewer items", + withItems( + savedPlan, + List.of(new ItemTake("input", 0, new ItemStack(Items.COPPER_ORE, 4)))), + "different item", + withItems( + savedPlan, List.of(new ItemTake("input", 0, new ItemStack(Items.IRON_ORE)))), + "product past the end", + new CyclePlan( + ID, + 200, + savedPlan.items(), + savedPlan.fluids(), + List.of(new Placed("output", copper.product(), List.of(new At(9, 2)))), + Map.of()), + "fewer mB", + new CyclePlan( + ID, + 200, + savedPlan.items(), + List.of(new FluidTake("water", 0, water.fluid().withAmount(2_000))), + savedPlan.products(), + Map.of())); + for (var entry : cases.entrySet()) { + var target = new Crusher(3, 1_000); + require(!entry.getValue().restore(target.inventory), entry.getKey() + " is refused"); + require(target.unplanned(), entry.getKey() + " leaves no plan"); + } + + // An unknown item is a decode error, not an exception. + var broken = tag.copy(); + broken + .getList("items", 10) + .getCompound(0) + .getCompound("stack") + .putString("id", "missing_mod:thing"); + require(CyclePlan.CODEC.parse(nbt, broken).error().isPresent(), "unknown item errors"); + } + + private static CyclePlan withItems(CyclePlan plan, List items) { + return new CyclePlan( + plan.recipe(), plan.time(), items, plan.fluids(), plan.products(), plan.energy()); + } + + /** A random whose nextFloat() is always {@code value}: 0 always hits a chance, 0.99 misses. */ + private static RandomSource fixed(float value) { + return new LegacyRandomSource(0) { + @Override + public float nextFloat() { + return value; + } + }; + } + + private static void require(boolean condition, String what) { + if (!condition) throw new AssertionError("Cycle plan smoke failed: " + what); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index 782a000..fe6dd9b 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -118,6 +118,12 @@ public static void machineRecipes(GameTestHelper helper) { helper.succeed(); } + @GameTest(template = EMPTY) + public static void cyclePlan(GameTestHelper helper) { + CyclePlanSmoke.check(helper.getLevel()); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void machineRecipeTypeRegistered(GameTestHelper helper) { MachineRecipeTypeSmoke.registered(); From b16851819679153c3ea0828a1f2080e5c5e4729e Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:05:07 +0200 Subject: [PATCH 11/72] feat(machine): features a host ticks and saves by key Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../lib/machine/feature/EnergySchedule.java | 17 +++ .../mc/lib/machine/feature/FeatureHost.java | 83 ++++++++++++++ .../lib/machine/feature/MachineFeature.java | 26 +++++ .../machine/feature/EnergyScheduleTest.java | 37 +++++++ .../lib/machine/feature/FeatureHostTest.java | 103 ++++++++++++++++++ 5 files changed, 266 insertions(+) create mode 100644 src/main/java/zone/moddev/mc/lib/machine/feature/EnergySchedule.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/feature/FeatureHost.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/feature/MachineFeature.java create mode 100644 src/test/java/zone/moddev/mc/lib/machine/feature/EnergyScheduleTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/machine/feature/FeatureHostTest.java diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/EnergySchedule.java b/src/main/java/zone/moddev/mc/lib/machine/feature/EnergySchedule.java new file mode 100644 index 0000000..7efdd18 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/EnergySchedule.java @@ -0,0 +1,17 @@ +package zone.moddev.mc.lib.machine.feature; + +/** How a cycle spreads its energy over its ticks. */ +final class EnergySchedule { + private EnergySchedule() {} + + /** + * The energy to draw on tick {@code tick} (from 0) of a cycle of {@code time} ticks that needs + * {@code total} in all. The shares add up to {@code total}, and none is above {@code + * ceil(total / time)}, so a check of the first tick's share is enough to start. + */ + static long share(long total, int time, int tick) { + long base = total / time; + long rest = total % time; + return base + (tick + 1L) * rest / time - tick * rest / time; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/FeatureHost.java b/src/main/java/zone/moddev/mc/lib/machine/feature/FeatureHost.java new file mode 100644 index 0000000..0588735 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/FeatureHost.java @@ -0,0 +1,83 @@ +package zone.moddev.mc.lib.machine.feature; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.jetbrains.annotations.Nullable; + +import com.mojang.serialization.DynamicOps; + +import net.minecraft.world.level.Level; + +/** + * Runs and saves a machine's {@link MachineFeature}s. The block entity ticks it and saves it with + * its own data; a feature calls {@link #markChanged} when its state changes. + */ +public final class FeatureHost { + private final Runnable onChange; + private final Map> features = new LinkedHashMap<>(); + @Nullable private Level level; + + /** A host that calls {@code onChange} when a feature says its state changed. */ + public FeatureHost(Runnable onChange) { + this.onChange = onChange; + } + + /** + * Adds {@code feature}; features tick in the order added. + * + * @throws IllegalArgumentException if another feature has the same key + */ + public FeatureHost add(MachineFeature feature) { + if (features.putIfAbsent(feature.key(), feature) != null) + throw new IllegalArgumentException("Duplicate machine feature " + feature.key()); + return this; + } + + /** Ticks every feature. Does nothing on the client. */ + public void tick(Level level) { + if (level.isClientSide()) return; + this.level = level; + for (var feature : features.values()) feature.tick(this); + } + + /** The level being ticked; only for use while a feature ticks. */ + public Level level() { + if (level == null) throw new IllegalStateException("Not ticking"); + return level; + } + + /** Tells the block entity that something worth saving changed. */ + public void markChanged() { + onChange.run(); + } + + /** Every feature's state, as a map from key to the state its codec writes. */ + public T save(DynamicOps ops) { + var map = new LinkedHashMap(); + for (var feature : features.values()) + map.put(ops.createString(feature.key()), encode(ops, feature)); + return ops.createMap(map); + } + + /** + * Gives each feature the state saved under its key. A feature with no data, or data its codec + * rejects, keeps its current state; data for a key no feature has is ignored. + */ + public void load(DynamicOps ops, T data) { + var map = ops.getMap(data).result().orElse(null); + if (map == null) return; + for (var feature : features.values()) { + var saved = map.get(feature.key()); + if (saved != null) decode(ops, feature, saved); + } + } + + private static T encode(DynamicOps ops, MachineFeature feature) { + return feature.codec().encodeStart(ops, feature.state()).getOrThrow(); + } + + private static void decode(DynamicOps ops, MachineFeature feature, T saved) { + feature.codec().parse(ops, saved).result().ifPresent(feature::restore); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/MachineFeature.java b/src/main/java/zone/moddev/mc/lib/machine/feature/MachineFeature.java new file mode 100644 index 0000000..7666e04 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/MachineFeature.java @@ -0,0 +1,26 @@ +package zone.moddev.mc.lib.machine.feature; + +import com.mojang.serialization.Codec; + +/** + * A piece of machine behaviour that ticks and keeps state, such as a {@link RecipeProcessor}. A + * {@link FeatureHost} runs a machine's features and saves each one under its key. + * + * @param the state that is saved + */ +public interface MachineFeature { + /** The name this feature's state is saved under; unique within a host. */ + String key(); + + /** How {@link #state} is saved. */ + Codec codec(); + + /** The state to save. */ + S state(); + + /** Takes on loaded state. */ + void restore(S state); + + /** Runs once a tick on the server. */ + default void tick(FeatureHost host) {} +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/feature/EnergyScheduleTest.java b/src/test/java/zone/moddev/mc/lib/machine/feature/EnergyScheduleTest.java new file mode 100644 index 0000000..f7fc3da --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/feature/EnergyScheduleTest.java @@ -0,0 +1,37 @@ +package zone.moddev.mc.lib.machine.feature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class EnergyScheduleTest { + private static final long[][] CASES = { + {4000, 200}, {100, 3}, {1, 200}, {0, 5}, {10_000_000_000L, 7}, {Long.MAX_VALUE, 1_000_003} + }; + + @Test + void sharesAddUpToTheTotal() { + for (var c : CASES) { + int time = (int) c[1]; + long sum = 0; + for (int tick = 0; tick < time; tick++) sum += EnergySchedule.share(c[0], time, tick); + assertEquals(c[0], sum, c[0] + " over " + time); + } + } + + @Test + void neverAboveTheStartingCheck() { + for (var c : CASES) { + int time = (int) c[1]; + long most = Math.ceilDiv(c[0], (long) time); + for (int tick = 0; tick < time; tick++) + assertTrue(EnergySchedule.share(c[0], time, tick) <= most, c[0] + " over " + time); + } + } + + @Test + void crusherIsEven() { + for (int tick = 0; tick < 200; tick++) assertEquals(20, EnergySchedule.share(4000, 200, tick)); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/feature/FeatureHostTest.java b/src/test/java/zone/moddev/mc/lib/machine/feature/FeatureHostTest.java new file mode 100644 index 0000000..b7a99b1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/feature/FeatureHostTest.java @@ -0,0 +1,103 @@ +package zone.moddev.mc.lib.machine.feature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.JsonOps; + +import org.junit.jupiter.api.Test; + +class FeatureHostTest { + /** A feature whose state is one number. */ + private static final class Counter implements MachineFeature { + private final String key; + int value; + + Counter(String key, int value) { + this.key = key; + this.value = value; + } + + @Override + public String key() { + return key; + } + + @Override + public Codec codec() { + return Codec.intRange(0, 100); + } + + @Override + public Integer state() { + return value; + } + + @Override + public void restore(Integer state) { + value = state; + } + } + + @Test + void savesAndLoadsByKey() { + var host = new FeatureHost(() -> {}).add(new Counter("a", 3)).add(new Counter("b", 7)); + JsonElement saved = host.save(JsonOps.INSTANCE); + assertEquals(3, saved.getAsJsonObject().get("a").getAsInt()); + assertEquals(7, saved.getAsJsonObject().get("b").getAsInt()); + + var a = new Counter("a", 0); + var b = new Counter("b", 0); + new FeatureHost(() -> {}).add(a).add(b).load(JsonOps.INSTANCE, saved); + assertEquals(3, a.value); + assertEquals(7, b.value); + } + + @Test + void duplicateKeyIsRefused() { + var host = new FeatureHost(() -> {}).add(new Counter("a", 0)); + assertThrows(IllegalArgumentException.class, () -> host.add(new Counter("a", 1))); + } + + @Test + void missingDataKeepsState() { + var a = new Counter("a", 5); + var b = new Counter("b", 6); + var data = new JsonObject(); + data.addProperty("a", 9); + new FeatureHost(() -> {}).add(a).add(b).load(JsonOps.INSTANCE, data); + assertEquals(9, a.value); + assertEquals(6, b.value); + + new FeatureHost(() -> {}).add(a).load(JsonOps.INSTANCE, JsonNull.INSTANCE); + assertEquals(9, a.value); + } + + @Test + void badDataKeepsState() { + var a = new Counter("a", 5); + var b = new Counter("b", 6); + var data = new JsonObject(); + data.add("a", new JsonPrimitive("nine")); + data.addProperty("b", 1000); + new FeatureHost(() -> {}).add(a).add(b).load(JsonOps.INSTANCE, data); + assertEquals(5, a.value); + assertEquals(6, b.value); + } + + @Test + void dataForAnUnknownKeyIsIgnored() { + var a = new Counter("a", 5); + var data = new JsonObject(); + data.addProperty("gone", 1); + data.addProperty("a", 2); + new FeatureHost(() -> {}).add(a).load(JsonOps.INSTANCE, data); + assertEquals(2, a.value); + } +} From 0222c7739ef4e51c8cc4524df18ffd8cde7ff83e Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:09:43 +0200 Subject: [PATCH 12/72] test(machine): a refining recipe type for the processor tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java | 4 ++++ .../recipe/refining/cobble_to_gravel.json | 10 ++++++++++ .../mmdlib_smoke/recipe/refining/crush_copper.json | 13 +++++++++++++ .../mmdlib_smoke/recipe/refining/iron_to_gold.json | 11 +++++++++++ 4 files changed, 38 insertions(+) create mode 100644 src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/cobble_to_gravel.json create mode 100644 src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/crush_copper.json create mode 100644 src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/iron_to_gold.json diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java index 5d4a37f..d553622 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java @@ -71,6 +71,10 @@ public final class MachineRecipeTypeSmoke { public static final MachineRecipeType GRINDING = MachineRecipeTypes.register(TYPES, SERIALIZERS, "grinding"); + /** A second test type, for the processor smoke test. */ + public static final MachineRecipeType REFINING = + MachineRecipeTypes.register(TYPES, SERIALIZERS, "refining"); + private static final ResourceLocation COPPER = id("grinding/copper"); private static final ResourceLocation IRON = id("grinding/iron"); diff --git a/src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/cobble_to_gravel.json b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/cobble_to_gravel.json new file mode 100644 index 0000000..59a4842 --- /dev/null +++ b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/cobble_to_gravel.json @@ -0,0 +1,10 @@ +{ + "type": "mmdlib_smoke:refining", + "inputs": { + "slot": [{ "items": { "item": "minecraft:cobblestone" } }] + }, + "outputs": { + "slot": [{ "item": { "id": "minecraft:gravel" } }] + }, + "time": 5 +} diff --git a/src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/crush_copper.json b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/crush_copper.json new file mode 100644 index 0000000..c3534b0 --- /dev/null +++ b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/crush_copper.json @@ -0,0 +1,13 @@ +{ + "type": "mmdlib_smoke:refining", + "inputs": { + "input": [{ "items": { "tag": "c:ores/copper" }, "count": 1 }], + "water": [{ "fluids": "minecraft:water", "amount": 250 }], + "energy": [{ "energy": 4000 }] + }, + "outputs": { + "output": [{ "item": { "id": "minecraft:raw_copper", "count": 2 } }], + "byproduct": [{ "item": { "id": "minecraft:gravel" }, "chance": 0.25 }] + }, + "time": 200 +} diff --git a/src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/iron_to_gold.json b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/iron_to_gold.json new file mode 100644 index 0000000..ef6da5a --- /dev/null +++ b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/refining/iron_to_gold.json @@ -0,0 +1,11 @@ +{ + "type": "mmdlib_smoke:refining", + "inputs": { + "slot": [{ "items": { "item": "minecraft:iron_ingot" } }], + "energy": [{ "energy": 100 }] + }, + "outputs": { + "slot": [{ "item": { "id": "minecraft:gold_ingot" } }] + }, + "time": 10 +} From 1a1fa8e67aa0d1cf1619b4edcf839b2261ac8e32 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:09:43 +0200 Subject: [PATCH 13/72] feat(machine): a recipe processor that holds what it needs while it runs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../lib/machine/feature/RecipeProcessor.java | 166 +++++++++++++ .../moddev/mc/lib/smoke/MMDLibGameTests.java | 48 ++++ .../moddev/mc/lib/smoke/ProcessorRig.java | 81 ++++++ .../moddev/mc/lib/smoke/ProcessorSmoke.java | 232 ++++++++++++++++++ 4 files changed, 527 insertions(+) create mode 100644 src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorRig.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java new file mode 100644 index 0000000..1597e2a --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java @@ -0,0 +1,166 @@ +package zone.moddev.mc.lib.machine.feature; + +import java.util.Optional; + +import org.jetbrains.annotations.Nullable; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.world.level.Level; + +import zone.moddev.mc.lib.machine.inventory.EnergyGroup; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.recipe.CyclePlan; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeLookup; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; + +/** + * Runs the recipes of one type on a machine's inventory, one cycle at a time. A cycle reserves its + * inputs and the room for its products when it starts, draws its energy evenly as it runs, and + * takes the inputs and makes the products in one step at the end. Nothing leaves a slot or tank + * before then, so a cycle that never finishes costs only the energy it used. + * + *

Several processors may share an inventory; a processor sees what the others reserved. + */ +public final class RecipeProcessor implements MachineFeature { + /** What the processor is doing. */ + public enum Status { + /** No cycle. */ + IDLE, + /** A cycle is running. */ + RUNNING, + /** A cycle is waiting for energy; it keeps its progress and reservations. */ + STALLED, + /** A cycle has run its time but cannot finish, say an input was taken; retried each tick. */ + BLOCKED, + /** Held by an interrupt. */ + PAUSED + } + + /** + * The saved form: the running cycle, if any, and its progress. + * + * @param cycle the running cycle + * @param progress ticks of {@code cycle} done + */ + public record State(Optional cycle, int progress) { + /** The save form. */ + public static final Codec CODEC = + RecordCodecBuilder.create( + instance -> + instance + .group( + CyclePlan.CODEC.optionalFieldOf("cycle").forGetter(State::cycle), + Codec.intRange(0, Integer.MAX_VALUE) + .optionalFieldOf("progress", 0) + .forGetter(State::progress)) + .apply(instance, State::new)); + } + + private final String key; + private final MachineInventory inventory; + private final MachineRecipeLookup lookup; + @Nullable private CyclePlan cycle; + private int progress; + private Status status = Status.IDLE; + + /** A processor saved under {@code key} that runs recipes of {@code type} on {@code inventory}. */ + public RecipeProcessor(String key, MachineRecipeType type, MachineInventory inventory) { + this.key = key; + this.inventory = inventory; + lookup = type.lookup(); + } + + @Override + public String key() { + return key; + } + + @Override + public Codec codec() { + return State.CODEC; + } + + @Override + public State state() { + return new State(Optional.ofNullable(cycle), progress); + } + + /** Takes on a saved cycle if its reservations can be put back; otherwise goes idle. */ + @Override + public void restore(State state) { + if (cycle != null) cycle.release(inventory); + cycle = state.cycle().filter(plan -> plan.restore(inventory)).orElse(null); + progress = cycle == null ? 0 : Math.min(state.progress(), cycle.time()); + status = cycle == null ? Status.IDLE : Status.RUNNING; + } + + /** What the processor is doing. */ + public Status status() { + return status; + } + + /** Ticks of the running cycle done. */ + public int progress() { + return progress; + } + + /** Ticks the running cycle takes; 0 when idle. */ + public int time() { + return cycle == null ? 0 : cycle.time(); + } + + @Override + public void tick(FeatureHost host) { + var level = host.level(); + if (cycle == null) { + start(level, host); + return; + } + if (progress < cycle.time()) { + if (!draw()) { + status = Status.STALLED; + return; + } + progress++; + status = Status.RUNNING; + host.markChanged(); + } + if (progress >= cycle.time()) { + if (cycle.finish(inventory, level.getRandom())) { + cycle = null; + progress = 0; + status = Status.IDLE; + host.markChanged(); + } else { + status = Status.BLOCKED; + } + } + } + + /** Reserves a cycle if a recipe can run. The starting tick draws no energy. */ + private void start(Level level, FeatureHost host) { + var found = lookup.find(level, inventory).orElse(null); + if (found == null) return; + var plan = CyclePlan.of(found.id(), found.value().recipe(), inventory).orElse(null); + if (plan == null || !plan.reserve(inventory)) return; + cycle = plan; + progress = 0; + status = Status.RUNNING; + host.markChanged(); + } + + /** Draws this tick's energy from every energy group, or none of it. */ + private boolean draw() { + try (var tx = inventory.begin()) { + for (var need : cycle.energy().entrySet()) { + long share = EnergySchedule.share(need.getValue(), cycle.time(), progress); + if (!(inventory.group(need.getKey()).orElse(null) instanceof EnergyGroup group) + || group.extract(share, tx) < share) return false; + } + tx.commit(); + return true; + } + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index fe6dd9b..e41efed 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -124,6 +124,54 @@ public static void cyclePlan(GameTestHelper helper) { helper.succeed(); } + @GameTest(template = EMPTY) + public static void processorWorkedRun(GameTestHelper helper) { + ProcessorSmoke.workedRun(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorHopperCantJam(GameTestHelper helper) { + ProcessorSmoke.hopperCantJam(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorTwoLanes(GameTestHelper helper) { + ProcessorSmoke.twoLanes(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorChanceKeepsNothing(GameTestHelper helper) { + ProcessorSmoke.chanceKeepsNothing(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorInputRemoved(GameTestHelper helper) { + ProcessorSmoke.inputRemoved(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorBlockedByOutput(GameTestHelper helper) { + ProcessorSmoke.blockedByOutput(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorNoPowerNeeded(GameTestHelper helper) { + ProcessorSmoke.noPowerNeeded(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorIdleDoesNotMarkChanged(GameTestHelper helper) { + ProcessorSmoke.idleDoesNotMarkChanged(helper.getLevel()); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void machineRecipeTypeRegistered(GameTestHelper helper) { MachineRecipeTypeSmoke.registered(); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorRig.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorRig.java new file mode 100644 index 0000000..d443c93 --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorRig.java @@ -0,0 +1,81 @@ +package zone.moddev.mc.lib.smoke; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.material.Fluids; + +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; + +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.machine.feature.FeatureHost; +import zone.moddev.mc.lib.machine.feature.RecipeProcessor; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.neoforge.StorageGroups; + +/** + * A machine's storages, inventory, processor and feature host for the processor smoke test. {@link + * #changes} counts every change the host or a storage reports. + */ +final class ProcessorRig { + final int[] changes = new int[1]; + final ItemStorage items; + final FluidStorage fluids = new FluidStorage().onChange(() -> changes[0]++); + final EnergyBuffer energy = new EnergyBuffer(10_000_000_000L, 1_000).onChange(() -> changes[0]++); + final MachineInventory inventory; + final FeatureHost host = new FeatureHost(() -> changes[0]++); + final RecipeProcessor processor; + + private ProcessorRig(ItemStorage storage, boolean crusher, boolean withEnergy) { + items = storage.onChange(() -> changes[0]++); + var builder = MachineInventory.builder(); + if (crusher) { + fluids.addTank(4_000, Role.INPUT); + builder + .group("input", StorageGroups.items(items, 0)) + .group("output", StorageGroups.items(items, 1, 2)) + .group("byproduct", StorageGroups.items(items, 3)) + .group("water", StorageGroups.fluids(fluids, 0)); + } else { + builder.group("slot", StorageGroups.items(items, 0)); + } + if (withEnergy) builder.group("energy", StorageGroups.energy(energy)); + inventory = builder.build(); + processor = new RecipeProcessor("work", MachineRecipeTypeSmoke.REFINING, inventory); + host.add(processor); + } + + /** The crusher of the worked run: ore in slot 0, output in 1 and 2, byproduct in 3. */ + static ProcessorRig crusher(int ore, int water, long fe) { + var storage = new ItemStorage(4).role(Role.INPUT, 0).role(Role.OUTPUT, 1, 2, 3); + var rig = new ProcessorRig(storage, true, true); + rig.items.insertResult(0, new ItemStack(Items.COPPER_ORE, ore), false); + rig.fluids.tank(0).fill(new FluidStack(Fluids.WATER, water), FluidAction.EXECUTE); + rig.energy.setStored(fe); + rig.changes[0] = 0; + return rig; + } + + /** One slot that is both the input and the output, with or without an energy buffer. */ + static ProcessorRig oneSlot(boolean withEnergy) { + var rig = new ProcessorRig(new ItemStorage(1), false, withEnergy); + rig.energy.setStored(1_000_000); + rig.changes[0] = 0; + return rig; + } + + /** A second processor on the same host and storages. */ + RecipeProcessor lane(String key) { + var lane = new RecipeProcessor(key, MachineRecipeTypeSmoke.REFINING, inventory); + host.add(lane); + return lane; + } + + void ticks(ServerLevel level, int count) { + for (int i = 0; i < count; i++) host.tick(level); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java new file mode 100644 index 0000000..9516700 --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java @@ -0,0 +1,232 @@ +package zone.moddev.mc.lib.smoke; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.machine.feature.RecipeProcessor.Status; + +/** Runs recipe processors tick by tick: the worked run, jams, lanes, chances and removed inputs. */ +final class ProcessorSmoke { + private ProcessorSmoke() {} + + /** The crusher's worked run, with inputs reserved rather than taken at the start. */ + static void workedRun(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 3_000); + + rig.ticks(level, 1); + expect(rig, Status.RUNNING, 0, "tick 0"); + require(rig.energy.stored() == 3_000, "the starting tick draws nothing"); + require(rig.items.getStackInSlot(0).getCount() == 3, "ore stays in the slot"); + require(rig.items.plan(0).take() == 1, "one ore reserved"); + require(rig.fluids.plan(0).take() == 250, "250 mB reserved"); + require(rig.processor.time() == 200, "time " + rig.processor.time()); + + rig.ticks(level, 1); + expect(rig, Status.RUNNING, 1, "tick 1"); + require(rig.energy.stored() == 2_980, "tick 1 energy " + rig.energy.stored()); + + rig.ticks(level, 149); + expect(rig, Status.RUNNING, 150, "tick 150"); + require(rig.energy.stored() == 0, "tick 150 energy " + rig.energy.stored()); + + for (int tick = 151; tick <= 160; tick++) { + rig.ticks(level, 1); + expect(rig, Status.STALLED, 150, "tick " + tick); + require(rig.items.plan(0).take() == 1 && rig.fluids.plan(0).take() == 250, "inputs held"); + require(!rig.items.plan(1).isNone(), "room held"); + } + + for (int tick = 161; tick <= 209; tick++) { + rig.energy.setStored(rig.energy.stored() + 200); + rig.ticks(level, 1); + expect(rig, Status.RUNNING, tick - 10, "tick " + tick); + if (tick == 161) require(rig.energy.stored() == 180, "tick 161 energy"); + } + + rig.energy.setStored(rig.energy.stored() + 200); + rig.ticks(level, 1); + require(rig.processor.status() == Status.IDLE, "tick 210 " + rig.processor.status()); + require(rig.processor.time() == 0, "idle has no time"); + require(rig.energy.stored() == 9_000, "tick 210 energy " + rig.energy.stored()); + require(rig.items.getStackInSlot(0).getCount() == 2, "ore taken at the end"); + require(rig.fluids.getFluidInTank(0).getAmount() == 750, "water taken at the end"); + require(rawCopper(rig) == 2, "two raw copper"); + require(unplanned(rig), "plans released"); + + rig.energy.setStored(rig.energy.stored() + 200); + rig.ticks(level, 1); + expect(rig, Status.RUNNING, 0, "tick 211"); + require(rig.energy.stored() == 9_200, "tick 211 energy " + rig.energy.stored()); + require(rig.items.plan(0).take() == 1, "the next ore is reserved"); + } + + /** A hopper pushing iron every tick can't use up the slot that is reserved for the gold. */ + static void hopperCantJam(ServerLevel level) { + var rig = ProcessorRig.oneSlot(true); + var hopper = rig.items.view(IoMode.BOTH); + rig.items.insertResult(0, new ItemStack(Items.IRON_INGOT), false); + int pushed = 0; + int refused = 0; + for (int tick = 0; tick < 30; tick++) { + rig.ticks(level, 1); + pushed++; + if (!hopper.insertItem(0, new ItemStack(Items.IRON_INGOT), false).isEmpty()) refused++; + } + require(refused == pushed, "the hopper was refused throughout: " + refused + "/" + pushed); + var held = rig.items.getStackInSlot(0); + require(held.is(Items.GOLD_INGOT) && held.getCount() == 1, "one gold, got " + held); + require(rig.processor.status() == Status.IDLE, "idle"); + } + + /** Two processors sharing storages never run on the same ore or the same room. */ + static void twoLanes(ServerLevel level) { + var rig = ProcessorRig.crusher(1, 1_000, 10_000); + var other = rig.lane("other"); + rig.ticks(level, 1); + require(rig.processor.status() == Status.RUNNING, "first lane runs"); + require(other.status() == Status.IDLE, "second lane has no ore"); + rig.ticks(level, 300); + require(rawCopper(rig) == 2, "one set of products, got " + rawCopper(rig)); + + rig = ProcessorRig.crusher(3, 1_000, 100_000); + other = rig.lane("other"); + rig.items.insertResult(1, new ItemStack(Items.RAW_COPPER, 62), false); + rig.items.insertResult(2, new ItemStack(Items.COBBLESTONE, 64), false); + rig.ticks(level, 1); + require(rig.processor.status() == Status.RUNNING, "first lane runs with room"); + require(other.status() == Status.IDLE, "second lane has no room"); + rig.ticks(level, 190); + require(other.status() == Status.IDLE, "second lane still waits"); + rig.ticks(level, 20); + require(rawCopper(rig) == 64, "only one set fit, got " + rawCopper(rig)); + } + + /** The gravel byproduct's space is only held for the cycle: a miss leaves it free. */ + static void chanceKeepsNothing(ServerLevel level) { + var rig = ProcessorRig.crusher(16, 4_000, 1_000_000); + boolean missed = false; + for (int cycle = 0; cycle < 16; cycle++) { + int gravel = rig.items.getStackInSlot(3).getCount(); + var rest = new ItemStack(Items.GRAVEL, 64 - gravel); + rig.ticks(level, 1); + require(rig.processor.status() == Status.RUNNING, "cycle " + cycle + " started"); + require(!rig.items.fits(3, rest), "gravel's space is held"); + rig.energy.setStored(1_000_000); + rig.ticks(level, 200); + require(rig.processor.status() == Status.IDLE, "cycle " + cycle + " finished"); + require(unplanned(rig), "plans released"); + int after = rig.items.getStackInSlot(3).getCount(); + require(rig.items.fits(3, new ItemStack(Items.GRAVEL, 64 - after)), "space is free again"); + missed |= after == gravel; + } + require(missed, "16 cycles at 25% never missed"); + } + + /** An input taken out mid-cycle blocks the finish and undoes nothing else. */ + static void inputRemoved(ServerLevel level) { + var rig = ProcessorRig.crusher(1, 1_000, 10_000); + rig.ticks(level, 101); + var ore = rig.items.getStackInSlot(0); + rig.items.setStackInSlot(0, ItemStack.EMPTY); + rig.ticks(level, 100); + require(rig.processor.progress() == 200, "progress " + rig.processor.progress()); + require(rig.processor.status() == Status.BLOCKED, "blocked " + rig.processor.status()); + long energy = rig.energy.stored(); + rig.ticks(level, 5); + require(rig.processor.status() == Status.BLOCKED, "still blocked"); + require(rig.processor.progress() == 200, "progress held at time"); + require(rig.energy.stored() == energy, "no energy while blocked"); + require(rawCopper(rig) == 0, "no product"); + require(rig.fluids.getFluidInTank(0).getAmount() == 1_000, "water untouched"); + require(!rig.items.plan(0).isNone() && !rig.fluids.plan(0).isNone(), "plans held"); + + rig.items.setStackInSlot(0, ore); + rig.ticks(level, 1); + require(rig.processor.status() == Status.IDLE, "finished once the ore is back"); + require(rawCopper(rig) == 2, "product made"); + require(rig.fluids.getFluidInTank(0).getAmount() == 750, "water taken"); + require(rig.items.getStackInSlot(0).isEmpty(), "ore taken"); + + rig = ProcessorRig.crusher(1, 1_000, 10_000); + rig.ticks(level, 101); + rig.items.setStackInSlot(0, new ItemStack(Items.DIRT)); + rig.ticks(level, 120); + require(rig.processor.status() == Status.BLOCKED, "blocked by dirt"); + require(rig.items.getStackInSlot(0).is(Items.DIRT), "dirt kept"); + require(rawCopper(rig) == 0, "no product from dirt"); + } + + /** A product slot filled mid-cycle blocks the finish, and the extract is undone. */ + static void blockedByOutput(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 10_000); + rig.ticks(level, 101); + rig.items.setStackInSlot(1, new ItemStack(Items.COBBLESTONE, 64)); + rig.ticks(level, 100); + require(rig.processor.status() == Status.BLOCKED, "blocked " + rig.processor.status()); + require(rig.items.getStackInSlot(0).getCount() == 3, "ore unchanged"); + require(rig.fluids.getFluidInTank(0).getAmount() == 1_000, "water unchanged"); + require(rig.items.plan(0).take() == 1 && !rig.items.plan(1).isNone(), "plans in place"); + long energy = rig.energy.stored(); + rig.ticks(level, 5); + require(rig.energy.stored() == energy, "no energy while blocked"); + require(rig.processor.status() == Status.BLOCKED, "still blocked"); + + rig.items.setStackInSlot(1, ItemStack.EMPTY); + rig.ticks(level, 1); + require(rig.processor.status() == Status.IDLE, "finished once there is room"); + require(rig.items.getStackInSlot(0).getCount() == 2, "ore taken"); + require(rig.fluids.getFluidInTank(0).getAmount() == 750, "water taken"); + require(rawCopper(rig) == 2, "product made"); + } + + /** A recipe without an energy group never waits for power. */ + static void noPowerNeeded(ServerLevel level) { + var rig = ProcessorRig.oneSlot(false); + rig.items.insertResult(0, new ItemStack(Items.COBBLESTONE), false); + for (int tick = 0; tick < 6; tick++) { + rig.ticks(level, 1); + require(rig.processor.status() != Status.STALLED, "stalled at tick " + tick); + } + require(rig.processor.status() == Status.IDLE, "done " + rig.processor.status()); + require(rig.items.getStackInSlot(0).is(Items.GRAVEL), "gravel made"); + } + + /** A machine with nothing to do does not ask to be saved. */ + static void idleDoesNotMarkChanged(ServerLevel level) { + var rig = ProcessorRig.crusher(0, 0, 0); + rig.ticks(level, 20); + require(rig.changes[0] == 0, "empty machine marked changed " + rig.changes[0] + " times"); + + rig = ProcessorRig.crusher(3, 0, 0); + rig.ticks(level, 20); + require(rig.changes[0] == 0, "machine that can't start marked changed"); + require(rig.processor.status() == Status.IDLE, "idle"); + } + + private static int rawCopper(ProcessorRig rig) { + int count = 0; + for (int slot = 1; slot <= 2; slot++) { + var stack = rig.items.getStackInSlot(slot); + if (stack.is(Items.RAW_COPPER)) count += stack.getCount(); + } + return count; + } + + private static boolean unplanned(ProcessorRig rig) { + for (int slot = 0; slot < rig.items.getSlots(); slot++) + if (!rig.items.plan(slot).isNone()) return false; + return rig.fluids.getTanks() == 0 || rig.fluids.plan(0).isNone(); + } + + private static void expect(ProcessorRig rig, Status status, int progress, String when) { + require( + rig.processor.status() == status && rig.processor.progress() == progress, + when + ": " + rig.processor.status() + " at " + rig.processor.progress()); + } + + private static void require(boolean condition, String what) { + if (!condition) throw new AssertionError("Processor smoke failed: " + what); + } +} From d79addc134103ce036df3e07405b221ae0c59e13 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:18:33 +0200 Subject: [PATCH 14/72] fix(machine): a stalled machine checks its energy before it draws Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/machine/feature/RecipeProcessor.java | 5 +++++ .../moddev/mc/lib/smoke/MMDLibGameTests.java | 6 ++++++ .../moddev/mc/lib/smoke/ProcessorSmoke.java | 17 +++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java index 1597e2a..03a87c4 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java @@ -153,6 +153,11 @@ private void start(Level level, FeatureHost host) { /** Draws this tick's energy from every energy group, or none of it. */ private boolean draw() { + for (var need : cycle.energy().entrySet()) { + long share = EnergySchedule.share(need.getValue(), cycle.time(), progress); + if (!(inventory.group(need.getKey()).orElse(null) instanceof EnergyGroup group) + || group.stored() < share) return false; + } try (var tx = inventory.begin()) { for (var need : cycle.energy().entrySet()) { long share = EnergySchedule.share(need.getValue(), cycle.time(), progress); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index e41efed..ca4c876 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -160,6 +160,12 @@ public static void processorBlockedByOutput(GameTestHelper helper) { helper.succeed(); } + @GameTest(template = EMPTY) + public static void processorPartialStallIsQuiet(GameTestHelper helper) { + ProcessorSmoke.partialStallIsQuiet(helper.getLevel()); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void processorNoPowerNeeded(GameTestHelper helper) { ProcessorSmoke.noPowerNeeded(helper.getLevel()); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java index 9516700..97e0e63 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java @@ -134,7 +134,9 @@ static void inputRemoved(ServerLevel level) { require(rig.processor.progress() == 200, "progress " + rig.processor.progress()); require(rig.processor.status() == Status.BLOCKED, "blocked " + rig.processor.status()); long energy = rig.energy.stored(); + int changes = rig.changes[0]; rig.ticks(level, 5); + require(rig.changes[0] == changes, "a blocked machine marked changed"); require(rig.processor.status() == Status.BLOCKED, "still blocked"); require(rig.processor.progress() == 200, "progress held at time"); require(rig.energy.stored() == energy, "no energy while blocked"); @@ -169,7 +171,9 @@ static void blockedByOutput(ServerLevel level) { require(rig.fluids.getFluidInTank(0).getAmount() == 1_000, "water unchanged"); require(rig.items.plan(0).take() == 1 && !rig.items.plan(1).isNone(), "plans in place"); long energy = rig.energy.stored(); + int changes = rig.changes[0]; rig.ticks(level, 5); + require(rig.changes[0] == changes, "a blocked machine marked changed"); require(rig.energy.stored() == energy, "no energy while blocked"); require(rig.processor.status() == Status.BLOCKED, "still blocked"); @@ -181,6 +185,19 @@ static void blockedByOutput(ServerLevel level) { require(rawCopper(rig) == 2, "product made"); } + /** A machine short of energy by part of a tick waits quietly. */ + static void partialStallIsQuiet(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 3_010); + rig.ticks(level, 151); + expect(rig, Status.RUNNING, 150, "tick 150"); + require(rig.energy.stored() == 10, "10 FE left " + rig.energy.stored()); + int changes = rig.changes[0]; + rig.ticks(level, 5); + expect(rig, Status.STALLED, 150, "stalled"); + require(rig.energy.stored() == 10, "nothing drawn"); + require(rig.changes[0] == changes, "a stalled machine marked changed"); + } + /** A recipe without an energy group never waits for power. */ static void noPowerNeeded(ServerLevel level) { var rig = ProcessorRig.oneSlot(false); From de356aae659e8a061b10903a16d66066219461d7 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:18:33 +0200 Subject: [PATCH 15/72] fix(machine): a cycle that can't finish checks before it opens a transaction Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/machine/recipe/CyclePlan.java | 63 +++++++++++++++++-- .../moddev/mc/lib/smoke/CyclePlanSmoke.java | 15 +++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java index 3484677..5fa9e8c 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java @@ -313,6 +313,11 @@ public void release(MachineInventory inventory) { */ public boolean finish(MachineInventory inventory, RandomSource random) { if (plans() == null) return false; + var hits = new ArrayList(); + for (var placed : products) + if (placed.product.chance() >= 1 || random.nextFloat() < placed.product.chance()) + hits.add(placed); + if (!canFinish(inventory, hits)) return false; try (var tx = inventory.begin()) { release(inventory, tx); for (var take : items) { @@ -331,17 +336,67 @@ public boolean finish(MachineInventory inventory, RandomSource random) { if (!held.sameFluid(take.fluid) || held.amount() < take.fluid.amount()) return false; if (group.extract(take.tank, take.fluid.amount(), tx) != take.fluid.amount()) return false; } - for (var placed : products) { - var product = placed.product; - if (product.chance() < 1 && random.nextFloat() >= product.chance()) continue; + for (var placed : hits) for (var at : placed.at) if (!insert(inventory, placed, at, tx)) return false; - } tx.commit(); return true; } } + /** + * Whether the inputs are still there and the rolled {@code hits} could still go where they were + * placed, looking only at what the slots and tanks hold, with this cycle's takes gone. Changes + * nothing and fires nothing, so a cycle that can't finish costs no transaction. A tank's + * capacity is left to the transaction. + */ + private boolean canFinish(MachineInventory inventory, List hits) { + var left = new HashMap(); + for (var take : items) { + var group = items(inventory, take.group); + if (group == null) return false; + var held = group.peek(take.slot); + var spot = new Spot(take.group, take.slot); + int rest = left.getOrDefault(spot, held.getCount()) - take.stack.getCount(); + if (!ItemStack.isSameItemSameComponents(held, take.stack) || rest < 0) return false; + left.put(spot, rest); + } + for (var take : fluids) { + var group = fluids(inventory, take.group); + if (group == null) return false; + var held = group.peek(take.tank); + var spot = new Spot(take.group, take.tank); + int rest = left.getOrDefault(spot, held.amount()) - take.fluid.amount(); + if (!held.sameFluid(take.fluid) || rest < 0) return false; + left.put(spot, rest); + } + var added = new HashMap(); + for (var placed : hits) + for (var at : placed.at) { + var spot = new Spot(placed.group, at.index); + int total = added.merge(spot, at.amount, Integer::sum); + switch (placed.product) { + case ItemProduct item -> { + var group = items(inventory, placed.group); + if (group == null) return false; + var held = group.peek(at.index); + int remaining = left.getOrDefault(spot, held.getCount()); + var stack = item.stack(); + if (remaining > 0 && !ItemStack.isSameItemSameComponents(held, stack)) return false; + if (remaining + total > stack.getMaxStackSize()) return false; + } + case FluidProduct fluid -> { + var group = fluids(inventory, placed.group); + if (group == null) return false; + var held = group.peek(at.index); + int remaining = left.getOrDefault(spot, held.amount()); + if (remaining > 0 && !held.sameFluid(fluid.fluid())) return false; + } + } + } + return true; + } + private boolean insert(MachineInventory inventory, Placed placed, At at, MachineTx tx) { return switch (placed.product) { case ItemProduct item -> { diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java index 8fd4648..0e74af7 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java @@ -194,6 +194,21 @@ static void check(ServerLevel level) { require(dirt.items.getStackInSlot(1).isEmpty(), "nothing inserted"); require(dirt.items.plan(0).sameAs(new SlotPlan(1, none)), "the plan stays on the slot"); + // Only a hit needs its space: a byproduct slot filled mid-cycle blocks a hit, not a miss. + var jammed = new Crusher(3, 1_000); + var jammedPlan = CyclePlan.of(ID, crusher, jammed.inventory).orElseThrow(); + jammedPlan.reserve(jammed.inventory); + jammed.items.setStackInSlot(3, new ItemStack(Items.COBBLESTONE, 64)); + require(!jammedPlan.finish(jammed.inventory, fixed(0F)), "a hit has no room"); + require( + jammed.items.getStackInSlot(0).getCount() == 3 + && jammed.items.getStackInSlot(1).isEmpty() + && jammed.items.plan(0).sameAs(new SlotPlan(1, none)), + "a blocked finish changes nothing"); + require(jammedPlan.finish(jammed.inventory, fixed(.5F)), "a miss needs no room"); + require(jammed.items.getStackInSlot(3).getCount() == 64, "the cobblestone stays"); + require(ItemStack.matches(jammed.items.getStackInSlot(1), rawCopper), "the dust is made"); + // release: this plan goes, another lane's plan on the same slot stays. var shared = new Crusher(3, 1_000); var other = new SlotPlan(1, none); From eaab0847344f809653c2f4d0cacaa5cc00f2f5c9 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:23:53 +0200 Subject: [PATCH 16/72] test(machine): a finish that fails inside its transaction is undone Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/machine/recipe/CyclePlan.java | 4 ++-- .../moddev/mc/lib/smoke/CyclePlanSmoke.java | 22 +++++++++++++++++++ .../moddev/mc/lib/smoke/ProcessorSmoke.java | 2 +- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java index 5fa9e8c..eb91412 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java @@ -304,7 +304,7 @@ public void release(MachineInventory inventory) { } /** - * In one transaction: releases this plan, takes the inputs, rolls each product's chance and + * Rolls each product's chance, then in one transaction releases this plan, takes the inputs and * inserts the hits where they were placed. A product that misses leaves its space free, and * nothing is kept of the roll, so a retry rolls again. * @@ -348,7 +348,7 @@ public boolean finish(MachineInventory inventory, RandomSource random) { * Whether the inputs are still there and the rolled {@code hits} could still go where they were * placed, looking only at what the slots and tanks hold, with this cycle's takes gone. Changes * nothing and fires nothing, so a cycle that can't finish costs no transaction. A tank's - * capacity is left to the transaction. + * capacity and a slot's limit are left to the transaction. */ private boolean canFinish(MachineInventory inventory, List hits) { var left = new HashMap(); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java index 0e74af7..b8c4528 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java @@ -11,6 +11,7 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerLevel; import net.minecraft.util.RandomSource; +import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.level.levelgen.LegacyRandomSource; @@ -194,6 +195,27 @@ static void check(ServerLevel level) { require(dirt.items.getStackInSlot(1).isEmpty(), "nothing inserted"); require(dirt.items.plan(0).sameAs(new SlotPlan(1, none)), "the plan stays on the slot"); + // A product the pre-check lets through but the slot refuses fails inside the transaction, + // after the inputs were taken and part of the products went in; all of it is undone. + var limited = new Crusher(3, 1_000); + var limitedPlan = CyclePlan.of(ID, crusher, limited.inventory).orElseThrow(); + limitedPlan.reserve(limited.inventory); + limited.items.limit(1, 1); + require(!limitedPlan.finish(limited.inventory, fixed(.99F)), "the slot takes 1 of 2"); + require( + limited.items.getStackInSlot(0).getCount() == 3 + && limited.fluids.tank(0).getFluidAmount() == 1_000 + && limited.items.getStackInSlot(1).isEmpty(), + "the failed transaction is undone"); + require( + limited.items.plan(0).sameAs(new SlotPlan(1, none)) + && limited.items.plan(1).sameAs(new SlotPlan(0, rawCopper)) + && limited.fluids.plan(0).take() == 250, + "the plans are back"); + limited.items.limit(Item.ABSOLUTE_MAX_STACK_SIZE, 1); + require(limitedPlan.finish(limited.inventory, fixed(.99F)), "finishes with room"); + require(ItemStack.matches(limited.items.getStackInSlot(1), rawCopper), "dust made"); + // Only a hit needs its space: a byproduct slot filled mid-cycle blocks a hit, not a miss. var jammed = new Crusher(3, 1_000); var jammedPlan = CyclePlan.of(ID, crusher, jammed.inventory).orElseThrow(); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java index 97e0e63..b5f01df 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java @@ -160,7 +160,7 @@ static void inputRemoved(ServerLevel level) { require(rawCopper(rig) == 0, "no product from dirt"); } - /** A product slot filled mid-cycle blocks the finish, and the extract is undone. */ + /** A product slot filled mid-cycle blocks the finish, and nothing is taken. */ static void blockedByOutput(ServerLevel level) { var rig = ProcessorRig.crusher(3, 1_000, 10_000); rig.ticks(level, 101); From ce77635f091e139fa2b07bed38f4de5abb04aca1 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:29:43 +0200 Subject: [PATCH 17/72] feat(machine): save and reload a running recipe A processor's state now carries paused, resume recipe and progress beside the cycle. A cycle that won't decode, can't be put back, or needs an energy group that is gone cancels; restore releases a running cycle first, since storages keep their plans. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../lib/machine/feature/RecipeProcessor.java | 65 ++++- .../moddev/mc/lib/smoke/MMDLibGameTests.java | 36 +++ .../moddev/mc/lib/smoke/ProcessorRig.java | 24 ++ .../moddev/mc/lib/smoke/ProcessorSmoke.java | 256 ++++++++++++++++++ 4 files changed, 371 insertions(+), 10 deletions(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java index 03a87c4..a411432 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java @@ -7,6 +7,7 @@ import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.Level; import zone.moddev.mc.lib.machine.inventory.EnergyGroup; @@ -39,22 +40,33 @@ public enum Status { } /** - * The saved form: the running cycle, if any, and its progress. + * The saved form. A cycle that won't decode, say because an item is gone from the game, is + * dropped as a whole while the rest of the state is kept. * + * @param paused whether an interrupt holds the processor + * @param progress ticks done of {@code cycle}, or of the recipe to resume + * @param resumeRecipe the recipe a pause or a resume is waiting to reserve again * @param cycle the running cycle - * @param progress ticks of {@code cycle} done */ - public record State(Optional cycle, int progress) { + public record State( + boolean paused, + int progress, + Optional resumeRecipe, + Optional cycle) { /** The save form. */ public static final Codec CODEC = RecordCodecBuilder.create( instance -> instance .group( - CyclePlan.CODEC.optionalFieldOf("cycle").forGetter(State::cycle), + Codec.BOOL.optionalFieldOf("paused", false).forGetter(State::paused), Codec.intRange(0, Integer.MAX_VALUE) .optionalFieldOf("progress", 0) - .forGetter(State::progress)) + .forGetter(State::progress), + ResourceLocation.CODEC + .optionalFieldOf("resume_recipe") + .forGetter(State::resumeRecipe), + CyclePlan.CODEC.lenientOptionalFieldOf("cycle").forGetter(State::cycle)) .apply(instance, State::new)); } @@ -63,6 +75,8 @@ public record State(Optional cycle, int progress) { private final MachineRecipeLookup lookup; @Nullable private CyclePlan cycle; private int progress; + @Nullable private ResourceLocation resumeRecipe; + private boolean paused; private Status status = Status.IDLE; /** A processor saved under {@code key} that runs recipes of {@code type} on {@code inventory}. */ @@ -84,16 +98,43 @@ public Codec codec() { @Override public State state() { - return new State(Optional.ofNullable(cycle), progress); + return new State( + paused, progress, Optional.ofNullable(resumeRecipe), Optional.ofNullable(cycle)); } - /** Takes on a saved cycle if its reservations can be put back; otherwise goes idle. */ + /** + * Takes on saved state. A running cycle is released first, since the storages keep their plans + * when they load their contents. A saved cycle whose plan can't be put back cancels, and the + * processor goes back to searching. + * + *

Load the storages before the {@link FeatureHost} loads, so the inputs are in place for the + * plan to check. + */ @Override public void restore(State state) { if (cycle != null) cycle.release(inventory); - cycle = state.cycle().filter(plan -> plan.restore(inventory)).orElse(null); - progress = cycle == null ? 0 : Math.min(state.progress(), cycle.time()); - status = cycle == null ? Status.IDLE : Status.RUNNING; + cycle = null; + paused = state.paused(); + resumeRecipe = state.resumeRecipe().orElse(null); + progress = resumeRecipe == null ? 0 : state.progress(); + var saved = state.cycle().filter(plan -> !paused && holds(plan)).orElse(null); + if (saved != null) { + cycle = saved; + progress = Math.min(state.progress(), saved.time()); + } + status = paused ? Status.PAUSED : cycle == null ? Status.IDLE : Status.RUNNING; + } + + /** Whether the plan's energy groups are there and its reservations can be put back. */ + private boolean holds(CyclePlan plan) { + for (var key : plan.energy().keySet()) + if (!(inventory.group(key).orElse(null) instanceof EnergyGroup)) return false; + return plan.restore(inventory); + } + + /** Whether an interrupt holds the processor. */ + public boolean paused() { + return paused; } /** What the processor is doing. */ @@ -113,6 +154,10 @@ public int time() { @Override public void tick(FeatureHost host) { + if (paused) { + status = Status.PAUSED; + return; + } var level = host.level(); if (cycle == null) { start(level, host); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index ca4c876..0164246 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -172,6 +172,42 @@ public static void processorNoPowerNeeded(GameTestHelper helper) { helper.succeed(); } + @GameTest(template = EMPTY) + public static void processorReloadResumes(GameTestHelper helper) { + ProcessorSmoke.reloadResumes(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorRecipeChanged(GameTestHelper helper) { + ProcessorSmoke.recipeChanged(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorPlanCantHold(GameTestHelper helper) { + ProcessorSmoke.planCantHold(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorPartialPlanCancels(GameTestHelper helper) { + ProcessorSmoke.partialPlanCancels(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorLoadedTwice(GameTestHelper helper) { + ProcessorSmoke.loadedTwice(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorLanesRestoreInEitherOrder(GameTestHelper helper) { + ProcessorSmoke.lanesRestoreInEitherOrder(helper.getLevel()); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void processorIdleDoesNotMarkChanged(GameTestHelper helper) { ProcessorSmoke.idleDoesNotMarkChanged(helper.getLevel()); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorRig.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorRig.java index d443c93..ba5ceca 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorRig.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorRig.java @@ -29,8 +29,12 @@ final class ProcessorRig { final MachineInventory inventory; final FeatureHost host = new FeatureHost(() -> changes[0]++); final RecipeProcessor processor; + private final boolean crusher; + private final boolean withEnergy; private ProcessorRig(ItemStorage storage, boolean crusher, boolean withEnergy) { + this.crusher = crusher; + this.withEnergy = withEnergy; items = storage.onChange(() -> changes[0]++); var builder = MachineInventory.builder(); if (crusher) { @@ -68,6 +72,26 @@ static ProcessorRig oneSlot(boolean withEnergy) { return rig; } + /** + * A fresh rig of the same shape whose storages are loaded from this one's saved data, as a block + * entity loads them before its features. Plans are not saved with the storages, so the copy has + * none. + */ + ProcessorRig reloaded(ServerLevel level) { + return reloaded(level, withEnergy); + } + + /** As {@link #reloaded(ServerLevel)}, with or without the energy group. */ + ProcessorRig reloaded(ServerLevel level, boolean withEnergy) { + var provider = level.registryAccess(); + var copy = crusher ? crusher(0, 0, 0) : oneSlot(withEnergy); + copy.items.deserializeNBT(provider, items.serializeNBT(provider)); + copy.fluids.deserializeNBT(provider, fluids.serializeNBT(provider)); + copy.energy.deserializeNBT(provider, energy.serializeNBT(provider)); + copy.changes[0] = 0; + return copy; + } + /** A second processor on the same host and storages. */ RecipeProcessor lane(String key) { var lane = new RecipeProcessor(key, MachineRecipeTypeSmoke.REFINING, inventory); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java index b5f01df..7799d7b 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java @@ -1,11 +1,39 @@ package zone.moddev.mc.lib.smoke; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.level.material.Fluids; + +import net.neoforged.neoforge.fluids.FluidStack; import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.machine.feature.RecipeProcessor.State; import zone.moddev.mc.lib.machine.feature.RecipeProcessor.Status; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; +import zone.moddev.mc.lib.machine.recipe.CyclePlan; +import zone.moddev.mc.lib.machine.recipe.CyclePlan.At; +import zone.moddev.mc.lib.machine.recipe.CyclePlan.ItemTake; +import zone.moddev.mc.lib.machine.recipe.CyclePlan.Placed; +import zone.moddev.mc.lib.machine.recipe.MachineProduct.ItemProduct; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeBuilder; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; /** Runs recipe processors tick by tick: the worked run, jams, lanes, chances and removed inputs. */ final class ProcessorSmoke { @@ -222,6 +250,234 @@ static void idleDoesNotMarkChanged(ServerLevel level) { require(rig.processor.status() == Status.IDLE, "idle"); } + /** A mid-cycle save and load puts back the same plans and progress, and the same finish. */ + static void reloadResumes(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 100_000); + rig.ticks(level, 101); + var copy = reload(level, rig); + expect(copy, Status.RUNNING, 100, "after the load"); + require(copy.processor.time() == 200, "time comes from the plan"); + for (int slot = 0; slot < 4; slot++) + require(copy.items.plan(slot).sameAs(rig.items.plan(slot)), "plan " + slot); + require(copy.fluids.plan(0).sameAs(rig.fluids.plan(0)), "tank plan"); + copy.ticks(level, 99); + expect(copy, Status.RUNNING, 199, "one tick short"); + copy.ticks(level, 1); + require(copy.processor.status() == Status.IDLE, "finished on time"); + require(rawCopper(copy) == 2, "products made"); + require(copy.items.getStackInSlot(0).getCount() == 2, "ore taken"); + require(copy.fluids.getFluidInTank(0).getAmount() == 750, "water taken"); + require(unplanned(copy), "plans released"); + } + + /** A running cycle keeps its recipe through a /reload; the next cycle uses the new one. */ + static void recipeChanged(ServerLevel level) { + var manager = level.getRecipeManager(); + var id = ResourceLocation.fromNamespaceAndPath("mmdlib_smoke", "refining/crush_copper"); + var originals = new ArrayList>(manager.getRecipes()); + try { + var rig = ProcessorRig.crusher(3, 1_000, 1_000_000); + rig.ticks(level, 101); + var diamonds = + MachineRecipeBuilder.recipe(MachineRecipeTypeSmoke.REFINING) + .inputItems("input", Ingredient.of(Items.COPPER_ORE), 1) + .inputFluid("water", Fluids.WATER, 250) + .inputEnergy("energy", 4_000) + .outputItem("output", new ItemStack(Items.DIAMOND, 5)) + .time(20) + .build(); + var changed = new ArrayList<>(originals); + changed.removeIf(holder -> holder.id().equals(id)); + var entry = new MachineRecipeEntry(MachineRecipeTypeSmoke.REFINING, diamonds); + changed.add(new RecipeHolder<>(id, entry)); + manager.replaceRecipes(changed); + require(rig.processor.time() == 200, "the cycle keeps its time"); + rig.ticks(level, 99); + expect(rig, Status.RUNNING, 199, "still the old time"); + rig.ticks(level, 1); + require(rawCopper(rig) == 2, "the old products, got " + rawCopper(rig)); + require(countOf(rig, Items.DIAMOND) == 0, "no diamonds from the old cycle"); + rig.ticks(level, 1); + expect(rig, Status.RUNNING, 0, "the next cycle starts"); + require(rig.processor.time() == 20, "on the new recipe, time " + rig.processor.time()); + rig.ticks(level, 20); + require(countOf(rig, Items.DIAMOND) == 5, "five diamonds"); + + manager.replaceRecipes(originals); + rig = ProcessorRig.crusher(3, 1_000, 1_000_000); + rig.ticks(level, 101); + var removed = new ArrayList<>(originals); + removed.removeIf(holder -> holder.id().equals(id)); + manager.replaceRecipes(removed); + rig.ticks(level, 100); + require(rig.processor.status() == Status.IDLE, "finished without its recipe"); + require(rawCopper(rig) == 2, "the products of the removed recipe"); + require(rig.items.getStackInSlot(0).getCount() == 2, "ore taken"); + rig.ticks(level, 5); + require(rig.processor.status() == Status.IDLE, "nothing more to run"); + require(unplanned(rig), "plans released"); + } finally { + manager.replaceRecipes(originals); + } + } + + /** A saved cycle that can't hold cancels, and a pause survives it. */ + static void planCantHold(ServerLevel level) { + var ops = level.registryAccess().createSerializationContext(JsonOps.INSTANCE); + for (boolean paused : new boolean[] {false, true}) { + var rig = ProcessorRig.crusher(3, 1_000, 100_000); + rig.ticks(level, 51); + var saved = rig.host.save(ops); + if (paused) saved.getAsJsonObject().getAsJsonObject("work").addProperty("paused", true); + + var missing = JsonParser.parseString(saved.toString().replace("copper_ore", "no_such_ore")); + var copy = rig.reloaded(level); + copy.host.load(ops, missing); + cancelled(copy, paused, "a missing item"); + + copy = rig.reloaded(level); + copy.items.setStackInSlot(0, ItemStack.EMPTY); + copy.host.load(ops, saved); + cancelled(copy, paused, "emptied inputs"); + + copy = rig.reloaded(level); + copy.fluids.tank(0).setFluid(FluidStack.EMPTY); + copy.host.load(ops, saved); + cancelled(copy, paused, "emptied tank"); + } + + var iron = new ItemStack(Items.IRON_INGOT); + var gold = ResourceLocation.fromNamespaceAndPath("mmdlib_smoke", "refining/iron_to_gold"); + var fewer = ProcessorRig.oneSlot(true); + fewer.items.insertResult(0, iron.copy(), false); + var outside = + new CyclePlan( + gold, 10, List.of(new ItemTake("slot", 1, iron)), List.of(), List.of(), Map.of()); + fewer.processor.restore(new State(false, 3, Optional.empty(), Optional.of(outside))); + cancelled(fewer, false, "fewer slots"); + + var running = ProcessorRig.oneSlot(true); + running.items.insertResult(0, iron.copy(), false); + running.ticks(level, 4); + require(running.processor.status() == Status.RUNNING, "the gold cycle runs"); + var noPower = running.reloaded(level, false); + noPower.host.load(ops, running.host.save(ops)); + cancelled(noPower, false, "an energy group that is gone"); + require(noPower.items.getStackInSlot(0).is(Items.IRON_INGOT), "the iron stays"); + } + + /** The cycle field drops the whole plan if any part of it won't decode, never a part of it. */ + static void partialPlanCancels(ServerLevel level) { + var ops = level.registryAccess().createSerializationContext(JsonOps.INSTANCE); + var recipe = ResourceLocation.fromNamespaceAndPath("mmdlib_smoke", "refining/crush_copper"); + var plan = + new CyclePlan( + recipe, + 200, + List.of( + new ItemTake("input", 0, new ItemStack(Items.COPPER_ORE)), + new ItemTake("output", 1, new ItemStack(Items.COBBLESTONE))), + List.of(), + List.of(), + Map.of()); + var state = new State(true, 50, Optional.empty(), Optional.of(plan)); + JsonElement json = State.CODEC.encodeStart(ops, state).getOrThrow(); + var broken = JsonParser.parseString(json.toString().replace("cobblestone", "no_such_thing")); + var decoded = State.CODEC.parse(ops, broken); + require(decoded.result().isPresent(), "the state still decodes: " + decoded); + var back = decoded.result().get(); + require(back.cycle().isEmpty(), "no part of a broken plan survives: " + back.cycle()); + require(back.paused() && back.progress() == 50, "the rest of the state is kept"); + var whole = State.CODEC.parse(ops, json).getOrThrow(); + require(whole.cycle().isPresent(), "an intact plan decodes"); + } + + /** Loading the same save twice leaves one cycle's plans, not two. */ + static void loadedTwice(ServerLevel level) { + var ops = level.registryAccess().createSerializationContext(NbtOps.INSTANCE); + var rig = ProcessorRig.crusher(3, 1_000, 100_000); + rig.ticks(level, 51); + Tag saved = rig.host.save(ops); + var copy = rig.reloaded(level); + copy.host.load(ops, saved); + int free = copy.items.freeToTake(0); + copy.host.load(ops, saved); + for (int slot = 0; slot < 4; slot++) + require(copy.items.plan(slot).sameAs(rig.items.plan(slot)), "plan " + slot); + require(copy.fluids.plan(0).sameAs(rig.fluids.plan(0)), "tank plan"); + require(copy.items.freeToTake(0) == free && free == 2, "free is unchanged: " + free); + expect(copy, Status.RUNNING, 50, "after the second load"); + copy.ticks(level, 150); + require(unplanned(copy) && rawCopper(copy) == 2, "one cycle's worth"); + } + + /** Two lanes' plans on one slot restore into the same merged plan in either order. */ + static void lanesRestoreInEitherOrder(ServerLevel level) { + var ops = level.registryAccess().createSerializationContext(NbtOps.INSTANCE); + var gold = ResourceLocation.fromNamespaceAndPath("mmdlib_smoke", "refining/iron_to_gold"); + var iron = new ItemStack(Items.IRON_INGOT); + var ingot = new ItemProduct(new ItemStack(Items.GOLD_INGOT), 1); + var takesIron = + new CyclePlan( + gold, 10, List.of(new ItemTake("slot", 0, iron)), List.of(), List.of(), Map.of()); + var addsGold = + new CyclePlan( + gold, + 10, + List.of(), + List.of(), + List.of(new Placed("slot", ingot, List.of(new At(0, 1)))), + Map.of()); + var merged = new SlotPlan(1, new ItemStack(Items.GOLD_INGOT)); + for (boolean goldFirst : new boolean[] {false, true}) { + var rig = ProcessorRig.oneSlot(true); + rig.items.insertResult(0, iron.copy(), false); + var other = rig.lane("other"); + var first = goldFirst ? addsGold : takesIron; + var second = goldFirst ? takesIron : addsGold; + rig.processor.restore(new State(false, 3, Optional.empty(), Optional.of(first))); + other.restore(new State(false, 3, Optional.empty(), Optional.of(second))); + require(rig.items.plan(0).sameAs(merged), "the source has the merged plan"); + + var copy = rig.reloaded(level); + var copyOther = copy.lane("other"); + copy.host.load(ops, rig.host.save(ops)); + var order = ", gold first " + goldFirst; + require(copy.processor.status() == Status.RUNNING, "first lane runs" + order); + require(copyOther.status() == Status.RUNNING, "second lane runs" + order); + require(copy.items.plan(0).sameAs(merged), "the merged plan" + order); + copy.ticks(level, 12); + require(copy.items.getStackInSlot(0).is(Items.GOLD_INGOT), "gold made" + order); + require(copy.items.getStackInSlot(0).getCount() == 1, "one gold" + order); + require(unplanned(copy), "plans released" + order); + } + } + + private static ProcessorRig reload(ServerLevel level, ProcessorRig rig) { + var ops = level.registryAccess().createSerializationContext(NbtOps.INSTANCE); + var copy = rig.reloaded(level); + copy.host.load(ops, rig.host.save(ops)); + return copy; + } + + /** The processor is idle, or paused if it was, with nothing reserved. */ + private static void cancelled(ProcessorRig rig, boolean paused, String why) { + var expected = paused ? Status.PAUSED : Status.IDLE; + require(rig.processor.status() == expected, why + ": " + rig.processor.status()); + require(rig.processor.paused() == paused, why + ": paused " + rig.processor.paused()); + require(rig.processor.progress() == 0 && rig.processor.time() == 0, why + ": progress"); + require(unplanned(rig), why + ": plans left behind"); + } + + private static int countOf(ProcessorRig rig, Item item) { + int count = 0; + for (int slot = 0; slot < rig.items.getSlots(); slot++) { + var stack = rig.items.getStackInSlot(slot); + if (stack.is(item)) count += stack.getCount(); + } + return count; + } + private static int rawCopper(ProcessorRig rig) { int count = 0; for (int slot = 1; slot <= 2; slot++) { From 6c57f7f6cd04163b2419b4e1e7b434c581a20f3d Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:32:29 +0200 Subject: [PATCH 18/72] feat(machine): save, reload and interrupt a running recipe interrupt() releases the cycle and remembers its recipe and progress; resume() lets the next tick reserve that recipe again, keeping progress only if it still fits. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../lib/machine/feature/RecipeProcessor.java | 62 +++++- .../moddev/mc/lib/smoke/MMDLibGameTests.java | 48 +++++ .../moddev/mc/lib/smoke/ProcessorSmoke.java | 184 ++++++++++++++++++ 3 files changed, 293 insertions(+), 1 deletion(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java index a411432..0855534 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java @@ -13,6 +13,7 @@ import zone.moddev.mc.lib.machine.inventory.EnergyGroup; import zone.moddev.mc.lib.machine.inventory.MachineInventory; import zone.moddev.mc.lib.machine.recipe.CyclePlan; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; import zone.moddev.mc.lib.machine.recipe.MachineRecipeLookup; import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; @@ -72,17 +73,20 @@ public record State( private final String key; private final MachineInventory inventory; + private final MachineRecipeType type; private final MachineRecipeLookup lookup; @Nullable private CyclePlan cycle; private int progress; @Nullable private ResourceLocation resumeRecipe; private boolean paused; + private boolean changed; private Status status = Status.IDLE; /** A processor saved under {@code key} that runs recipes of {@code type} on {@code inventory}. */ public RecipeProcessor(String key, MachineRecipeType type, MachineInventory inventory) { this.key = key; this.inventory = inventory; + this.type = type; lookup = type.lookup(); } @@ -137,6 +141,34 @@ public boolean paused() { return paused; } + /** + * Pauses the processor: stops drawing energy, releases the running cycle's reservations so its + * items can be taken, and starts nothing new until {@link #resume}. Works without power and + * whatever the status. The recipe and progress are kept for the resume. + */ + public void interrupt() { + if (paused) return; + if (cycle != null) { + cycle.release(inventory); + resumeRecipe = cycle.recipe(); + cycle = null; + } + paused = true; + status = Status.PAUSED; + changed = true; + } + + /** + * Lets a paused processor run again. The next tick reserves the paused recipe again if it still + * can, and continues its progress; otherwise progress is lost and the processor searches. + */ + public void resume() { + if (!paused) return; + paused = false; + status = Status.IDLE; + changed = true; + } + /** What the processor is doing. */ public Status status() { return status; @@ -154,12 +186,16 @@ public int time() { @Override public void tick(FeatureHost host) { + if (changed) { + changed = false; + host.markChanged(); + } if (paused) { status = Status.PAUSED; return; } var level = host.level(); - if (cycle == null) { + if (cycle == null && (resumeRecipe == null || !reserveAgain(level, host))) { start(level, host); return; } @@ -184,6 +220,30 @@ public void tick(FeatureHost host) { } } + /** Reserves the paused recipe again if it still exists and fits; otherwise loses its progress. */ + private boolean reserveAgain(Level level, FeatureHost host) { + var id = resumeRecipe; + resumeRecipe = null; + host.markChanged(); + CyclePlan plan = null; + var holder = level.getRecipeManager().byKey(id).orElse(null); + if (holder != null + && holder.value() instanceof MachineRecipeEntry entry + && entry.type() == type) { + plan = + CyclePlan.of(id, entry.recipe(), inventory) + .filter(found -> found.reserve(inventory)) + .orElse(null); + } + if (plan == null) { + progress = 0; + return false; + } + cycle = plan; + progress = Math.min(progress, plan.time()); + return true; + } + /** Reserves a cycle if a recipe can run. The starting tick draws no energy. */ private void start(Level level, FeatureHost host) { var found = lookup.find(level, inventory).orElse(null); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index 0164246..2b63c0c 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -208,6 +208,54 @@ public static void processorLanesRestoreInEitherOrder(GameTestHelper helper) { helper.succeed(); } + @GameTest(template = EMPTY) + public static void processorInterruptFrees(GameTestHelper helper) { + ProcessorSmoke.interruptFrees(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorInterruptWorksWhileStalled(GameTestHelper helper) { + ProcessorSmoke.interruptWorksWhileStalled(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorInterruptClearsBlocked(GameTestHelper helper) { + ProcessorSmoke.interruptClearsBlocked(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorResumeKeepsProgress(GameTestHelper helper) { + ProcessorSmoke.resumeKeepsProgress(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorResumeWithoutPower(GameTestHelper helper) { + ProcessorSmoke.resumeWithoutPower(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorResumeResets(GameTestHelper helper) { + ProcessorSmoke.resumeResets(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorPausedStaysPaused(GameTestHelper helper) { + ProcessorSmoke.pausedStaysPaused(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void processorPausedSavesItsPlace(GameTestHelper helper) { + ProcessorSmoke.pausedSavesItsPlace(helper.getLevel()); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void processorIdleDoesNotMarkChanged(GameTestHelper helper) { ProcessorSmoke.idleDoesNotMarkChanged(helper.getLevel()); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java index 7799d7b..533b4d1 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java @@ -453,6 +453,190 @@ static void lanesRestoreInEitherOrder(ServerLevel level) { } } + /** An interrupt frees every reservation, draws nothing, and lets the items be taken. */ + static void interruptFrees(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 100_000); + rig.ticks(level, 51); + require(rig.items.extractItem(0, 3, true).getCount() == 2, "one ore is locked"); + long energy = rig.energy.stored(); + rig.processor.interrupt(); + require(unplanned(rig), "every plan is NONE"); + require(rig.processor.paused(), "paused"); + require(rig.processor.status() == Status.PAUSED, "status " + rig.processor.status()); + rig.ticks(level, 20); + require(rig.energy.stored() == energy, "no energy drawn"); + require(rig.processor.progress() == 50, "progress kept"); + require(rig.processor.status() == Status.PAUSED, "still paused"); + require(rig.items.extractItem(0, 3, false).getCount() == 3, "all the ore can be taken"); + require(rig.fluids.getFluidInTank(0).getAmount() == 1_000, "water untouched"); + } + + /** An interrupt needs no power: a stalled machine frees everything. */ + static void interruptWorksWhileStalled(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 3_000); + rig.ticks(level, 156); + require(rig.processor.status() == Status.STALLED, "stalled " + rig.processor.status()); + require(rig.energy.stored() == 0, "no power"); + rig.processor.interrupt(); + require(unplanned(rig), "every plan is NONE"); + require(rig.processor.status() == Status.PAUSED, "paused"); + require(rig.items.extractItem(0, 3, false).getCount() == 3, "the ore can be taken"); + } + + /** Interrupting a blocked cycle clears it; resume finds nothing to continue and searches. */ + static void interruptClearsBlocked(ServerLevel level) { + var rig = ProcessorRig.crusher(1, 1_000, 100_000); + rig.ticks(level, 101); + var ore = rig.items.getStackInSlot(0); + rig.items.setStackInSlot(0, ItemStack.EMPTY); + rig.ticks(level, 100); + require(rig.processor.status() == Status.BLOCKED, "blocked " + rig.processor.status()); + rig.processor.interrupt(); + require(unplanned(rig), "plans cleared"); + require(rig.processor.status() == Status.PAUSED, "paused"); + rig.processor.resume(); + rig.ticks(level, 1); + expect(rig, Status.IDLE, 0, "resumed without the ore"); + require(!rig.processor.paused(), "no longer paused"); + rig.items.setStackInSlot(0, ore); + rig.ticks(level, 1); + expect(rig, Status.RUNNING, 0, "searching found the ore"); + } + + /** The same recipe on the same contents continues from its progress. */ + static void resumeKeepsProgress(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 100_000); + rig.ticks(level, 101); + rig.processor.interrupt(); + rig.ticks(level, 10); + rig.processor.resume(); + rig.ticks(level, 1); + expect(rig, Status.RUNNING, 101, "resumed and drew"); + require(rig.items.plan(0).take() == 1 && rig.fluids.plan(0).take() == 250, "reserved again"); + rig.ticks(level, 98); + expect(rig, Status.RUNNING, 199, "one tick short"); + rig.ticks(level, 1); + require(rig.processor.status() == Status.IDLE, "finished"); + require(rawCopper(rig) == 2 && rig.items.getStackInSlot(0).getCount() == 2, "products made"); + } + + /** Resuming without power puts the reservations back and waits. */ + static void resumeWithoutPower(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 3_000); + rig.ticks(level, 151); + expect(rig, Status.RUNNING, 150, "tick 150"); + rig.processor.interrupt(); + rig.processor.resume(); + rig.ticks(level, 1); + expect(rig, Status.STALLED, 150, "resumed with an empty buffer"); + require(rig.items.plan(0).take() == 1 && rig.fluids.plan(0).take() == 250, "inputs held"); + require(!rig.items.plan(1).isNone(), "room held"); + rig.energy.setStored(200); + rig.ticks(level, 1); + expect(rig, Status.RUNNING, 151, "power back"); + } + + /** Progress is lost when the inputs, the room or the recipe are no longer there. */ + static void resumeResets(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 100_000); + rig.ticks(level, 101); + rig.processor.interrupt(); + rig.items.setStackInSlot(0, ItemStack.EMPTY); + rig.processor.resume(); + rig.ticks(level, 1); + expect(rig, Status.IDLE, 0, "inputs taken"); + require(unplanned(rig), "nothing reserved"); + rig.items.setStackInSlot(0, new ItemStack(Items.COPPER_ORE, 3)); + rig.ticks(level, 1); + expect(rig, Status.RUNNING, 0, "searching again"); + + rig = ProcessorRig.crusher(3, 1_000, 100_000); + rig.ticks(level, 101); + rig.processor.interrupt(); + rig.items.setStackInSlot(1, new ItemStack(Items.COBBLESTONE, 64)); + rig.items.setStackInSlot(2, new ItemStack(Items.COBBLESTONE, 64)); + rig.processor.resume(); + rig.ticks(level, 1); + expect(rig, Status.IDLE, 0, "the room filled"); + require(unplanned(rig), "nothing reserved"); + rig.items.setStackInSlot(2, ItemStack.EMPTY); + rig.ticks(level, 1); + expect(rig, Status.RUNNING, 0, "searching again with room"); + + var withdrawn = ProcessorRig.crusher(3, 1_000, 100_000); + withdrawn.ticks(level, 101); + withdrawn.processor.interrupt(); + withoutCrushCopper( + level, + () -> { + withdrawn.processor.resume(); + withdrawn.ticks(level, 1); + }); + expect(withdrawn, Status.IDLE, 0, "the recipe is gone"); + require(unplanned(withdrawn), "nothing reserved"); + } + + /** A pause holds an idle machine too, through a save and load. */ + static void pausedStaysPaused(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 100_000); + rig.processor.interrupt(); + rig.ticks(level, 20); + expect(rig, Status.PAUSED, 0, "idle and paused"); + require(unplanned(rig), "nothing started"); + + var copy = reload(level, rig); + require(copy.processor.paused(), "paused after the load"); + copy.ticks(level, 20); + expect(copy, Status.PAUSED, 0, "still paused after the load"); + require(unplanned(copy), "nothing started after the load"); + copy.processor.resume(); + copy.ticks(level, 1); + expect(copy, Status.RUNNING, 0, "starts once resumed"); + + var quiet = ProcessorRig.crusher(0, 0, 0); + quiet.processor.interrupt(); + quiet.ticks(level, 1); + require(quiet.changes[0] == 1, "an interrupt asks to be saved: " + quiet.changes[0]); + quiet.processor.resume(); + quiet.ticks(level, 1); + require(quiet.changes[0] == 2, "a resume asks to be saved: " + quiet.changes[0]); + quiet.ticks(level, 5); + require(quiet.changes[0] == 2, "and only once"); + } + + /** A paused machine saves which recipe and how far, with no cycle, and resumes after a load. */ + static void pausedSavesItsPlace(ServerLevel level) { + var rig = ProcessorRig.crusher(3, 1_000, 100_000); + rig.ticks(level, 51); + rig.processor.interrupt(); + var state = rig.processor.state(); + var recipe = ResourceLocation.fromNamespaceAndPath("mmdlib_smoke", "refining/crush_copper"); + require(state.paused() && state.progress() == 50, "paused at 50: " + state); + require(state.resumeRecipe().equals(Optional.of(recipe)), "the recipe: " + state); + require(state.cycle().isEmpty(), "no cycle"); + + var copy = reload(level, rig); + expect(copy, Status.PAUSED, 50, "after the load"); + require(unplanned(copy), "nothing reserved"); + copy.processor.resume(); + copy.ticks(level, 1); + expect(copy, Status.RUNNING, 51, "continues from the saved progress"); + } + + private static void withoutCrushCopper(ServerLevel level, Runnable body) { + var manager = level.getRecipeManager(); + var id = ResourceLocation.fromNamespaceAndPath("mmdlib_smoke", "refining/crush_copper"); + var originals = new ArrayList>(manager.getRecipes()); + var removed = new ArrayList<>(originals); + removed.removeIf(holder -> holder.id().equals(id)); + try { + manager.replaceRecipes(removed); + body.run(); + } finally { + manager.replaceRecipes(originals); + } + } + private static ProcessorRig reload(ServerLevel level, ProcessorRig rig) { var ops = level.registryAccess().createSerializationContext(NbtOps.INSTANCE); var copy = rig.reloaded(level); From e9c2bdea233acadf2f0d0e99de2357ce3649c2b4 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:40:29 +0200 Subject: [PATCH 19/72] docs(machine): FeatureHost.load wants the storages loaded first Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../java/zone/moddev/mc/lib/machine/feature/FeatureHost.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/FeatureHost.java b/src/main/java/zone/moddev/mc/lib/machine/feature/FeatureHost.java index 0588735..012fd1c 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/feature/FeatureHost.java +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/FeatureHost.java @@ -63,6 +63,8 @@ public T save(DynamicOps ops) { /** * Gives each feature the state saved under its key. A feature with no data, or data its codec * rejects, keeps its current state; data for a key no feature has is ignored. + * + *

Load the storages first: a feature checks the loaded contents against what it saved. */ public void load(DynamicOps ops, T data) { var map = ops.getMap(data).result().orElse(null); From 0108e4ef6681168ce0a9513c477a737fefd0d7bf Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:40:54 +0200 Subject: [PATCH 20/72] fix(machine): progress survives a pause only for a recipe of the same time A paused processor saves the time its recipe took; if the current definition takes longer or shorter, resume restarts it from 0 rather than carrying the old ticks over. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../lib/machine/feature/RecipeProcessor.java | 21 ++++++- .../moddev/mc/lib/smoke/MMDLibGameTests.java | 6 ++ .../moddev/mc/lib/smoke/ProcessorSmoke.java | 55 +++++++++++++++++-- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java index 0855534..a782dfd 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java @@ -47,12 +47,14 @@ public enum Status { * @param paused whether an interrupt holds the processor * @param progress ticks done of {@code cycle}, or of the recipe to resume * @param resumeRecipe the recipe a pause or a resume is waiting to reserve again + * @param resumeTime the time that recipe took, so progress is dropped if its time changed * @param cycle the running cycle */ public record State( boolean paused, int progress, Optional resumeRecipe, + int resumeTime, Optional cycle) { /** The save form. */ public static final Codec CODEC = @@ -67,6 +69,9 @@ public record State( ResourceLocation.CODEC .optionalFieldOf("resume_recipe") .forGetter(State::resumeRecipe), + Codec.intRange(0, Integer.MAX_VALUE) + .optionalFieldOf("resume_time", 0) + .forGetter(State::resumeTime), CyclePlan.CODEC.lenientOptionalFieldOf("cycle").forGetter(State::cycle)) .apply(instance, State::new)); } @@ -78,6 +83,7 @@ public record State( @Nullable private CyclePlan cycle; private int progress; @Nullable private ResourceLocation resumeRecipe; + private int resumeTime; private boolean paused; private boolean changed; private Status status = Status.IDLE; @@ -103,7 +109,11 @@ public Codec codec() { @Override public State state() { return new State( - paused, progress, Optional.ofNullable(resumeRecipe), Optional.ofNullable(cycle)); + paused, + progress, + Optional.ofNullable(resumeRecipe), + resumeTime, + Optional.ofNullable(cycle)); } /** @@ -120,6 +130,7 @@ public void restore(State state) { cycle = null; paused = state.paused(); resumeRecipe = state.resumeRecipe().orElse(null); + resumeTime = state.resumeTime(); progress = resumeRecipe == null ? 0 : state.progress(); var saved = state.cycle().filter(plan -> !paused && holds(plan)).orElse(null); if (saved != null) { @@ -151,6 +162,7 @@ public void interrupt() { if (cycle != null) { cycle.release(inventory); resumeRecipe = cycle.recipe(); + resumeTime = cycle.time(); cycle = null; } paused = true; @@ -220,7 +232,10 @@ public void tick(FeatureHost host) { } } - /** Reserves the paused recipe again if it still exists and fits; otherwise loses its progress. */ + /** + * Reserves the paused recipe again if it still exists and fits. Progress is kept only if the + * recipe takes as long as it did; if it can't be reserved, the processor searches instead. + */ private boolean reserveAgain(Level level, FeatureHost host) { var id = resumeRecipe; resumeRecipe = null; @@ -240,7 +255,7 @@ private boolean reserveAgain(Level level, FeatureHost host) { return false; } cycle = plan; - progress = Math.min(progress, plan.time()); + if (plan.time() != resumeTime) progress = 0; return true; } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index 2b63c0c..cdd9159 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -244,6 +244,12 @@ public static void processorResumeResets(GameTestHelper helper) { helper.succeed(); } + @GameTest(template = EMPTY) + public static void processorResumeAfterRedefinition(GameTestHelper helper) { + ProcessorSmoke.resumeAfterRedefinition(helper.getLevel()); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void processorPausedStaysPaused(GameTestHelper helper) { ProcessorSmoke.pausedStaysPaused(helper.getLevel()); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java index 533b4d1..8f19038 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java @@ -353,7 +353,7 @@ static void planCantHold(ServerLevel level) { var outside = new CyclePlan( gold, 10, List.of(new ItemTake("slot", 1, iron)), List.of(), List.of(), Map.of()); - fewer.processor.restore(new State(false, 3, Optional.empty(), Optional.of(outside))); + fewer.processor.restore(new State(false, 3, Optional.empty(), 0, Optional.of(outside))); cancelled(fewer, false, "fewer slots"); var running = ProcessorRig.oneSlot(true); @@ -380,7 +380,7 @@ static void partialPlanCancels(ServerLevel level) { List.of(), List.of(), Map.of()); - var state = new State(true, 50, Optional.empty(), Optional.of(plan)); + var state = new State(true, 50, Optional.empty(), 0, Optional.of(plan)); JsonElement json = State.CODEC.encodeStart(ops, state).getOrThrow(); var broken = JsonParser.parseString(json.toString().replace("cobblestone", "no_such_thing")); var decoded = State.CODEC.parse(ops, broken); @@ -435,8 +435,8 @@ static void lanesRestoreInEitherOrder(ServerLevel level) { var other = rig.lane("other"); var first = goldFirst ? addsGold : takesIron; var second = goldFirst ? takesIron : addsGold; - rig.processor.restore(new State(false, 3, Optional.empty(), Optional.of(first))); - other.restore(new State(false, 3, Optional.empty(), Optional.of(second))); + rig.processor.restore(new State(false, 3, Optional.empty(), 0, Optional.of(first))); + other.restore(new State(false, 3, Optional.empty(), 0, Optional.of(second))); require(rig.items.plan(0).sameAs(merged), "the source has the merged plan"); var copy = rig.reloaded(level); @@ -623,6 +623,53 @@ static void pausedSavesItsPlace(ServerLevel level) { expect(copy, Status.RUNNING, 51, "continues from the saved progress"); } + /** Progress survives a pause only for a recipe that takes as long as it did. */ + static void resumeAfterRedefinition(ServerLevel level) { + for (int time : new int[] {20, 400}) { + for (boolean saved : new boolean[] {false, true}) { + var rig = ProcessorRig.crusher(3, 1_000, 1_000_000); + rig.ticks(level, 101); + rig.processor.interrupt(); + var running = saved ? reload(level, rig) : rig; + var what = "time " + time + (saved ? " saved" : ""); + replacingCrushCopper( + level, + time, + () -> { + running.processor.resume(); + running.ticks(level, 1); + expect(running, Status.RUNNING, 1, "restarted, " + what); + require(running.processor.time() == time, "the new time, " + what); + require(countOf(running, Items.DIAMOND) == 0, "nothing finished early, " + what); + }); + } + } + } + + private static void replacingCrushCopper(ServerLevel level, int time, Runnable body) { + var manager = level.getRecipeManager(); + var id = ResourceLocation.fromNamespaceAndPath("mmdlib_smoke", "refining/crush_copper"); + var originals = new ArrayList>(manager.getRecipes()); + var recipe = + MachineRecipeBuilder.recipe(MachineRecipeTypeSmoke.REFINING) + .inputItems("input", Ingredient.of(Items.COPPER_ORE), 1) + .inputFluid("water", Fluids.WATER, 250) + .inputEnergy("energy", 4_000) + .outputItem("output", new ItemStack(Items.DIAMOND, 5)) + .time(time) + .build(); + var changed = new ArrayList<>(originals); + changed.removeIf(holder -> holder.id().equals(id)); + var entry = new MachineRecipeEntry(MachineRecipeTypeSmoke.REFINING, recipe); + changed.add(new RecipeHolder<>(id, entry)); + try { + manager.replaceRecipes(changed); + body.run(); + } finally { + manager.replaceRecipes(originals); + } + } + private static void withoutCrushCopper(ServerLevel level, Runnable body) { var manager = level.getRecipeManager(); var id = ResourceLocation.fromNamespaceAndPath("mmdlib_smoke", "refining/crush_copper"); From 77570d8ec44843eac500147ede671bf221d27a73 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 3 Nov 2026 06:51:56 +0200 Subject: [PATCH 21/72] test(machine): an example crusher that saves, reloads and breaks mid-cycle Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../moddev/mc/lib/smoke/CrusherSmoke.java | 298 ++++++++++++++++++ .../moddev/mc/lib/smoke/MMDLibGameTests.java | 7 + .../moddev/mc/lib/smoke/MachineSmoke.java | 22 ++ 3 files changed, 327 insertions(+) create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/CrusherSmoke.java diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CrusherSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CrusherSmoke.java new file mode 100644 index 0000000..2525a8f --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CrusherSmoke.java @@ -0,0 +1,298 @@ +package zone.moddev.mc.lib.smoke; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import com.mojang.serialization.Codec; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.entity.HopperBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.Fluids; +import net.minecraft.world.phys.AABB; + +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent; +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; +import net.neoforged.neoforge.items.IItemHandlerModifiable; + +import zone.moddev.mc.lib.api.MaterialEventTypes; +import zone.moddev.mc.lib.api.MaterialExtensions; +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.MachineCapabilities; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.common.factories.BlockEntityFactory; +import zone.moddev.mc.lib.common.types.MMDBlockEntity; +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.load.ScreenBinder; +import zone.moddev.mc.lib.gui.load.ScreenReader; +import zone.moddev.mc.lib.gui.runtime.MachineMenu; +import zone.moddev.mc.lib.gui.runtime.ScreenHost; +import zone.moddev.mc.lib.gui.runtime.ScreenNetwork; +import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.machine.feature.FeatureHost; +import zone.moddev.mc.lib.machine.feature.RecipeProcessor; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.neoforge.StorageGroups; + +/** + * The example crusher of the machine module: a block entity with storages, a recipe processor and a + * screen, and the checks that drive its menu the way a player does. + */ +@EventBusSubscriber(modid = "mmdlib", bus = EventBusSubscriber.Bus.MOD) +public final class CrusherSmoke { + static final ResourceLocation ID = ResourceLocation.parse("mmdlib_smoke:crusher"); + static final ResourceLocation ENTITY_FACTORY = + ResourceLocation.parse("mmdlib_smoke:crusher_entity"); + private static final ResourceLocation INTERRUPT = + ResourceLocation.parse("mmdlib_smoke:crusher_interrupt"); + + private CrusherSmoke() {} + + @SubscribeEvent + static void capabilities(RegisterCapabilitiesEvent event) { + @SuppressWarnings("unchecked") + var type = (BlockEntityType) MachineSmoke.registrar().blockEntityTypes().get(ID).get(); + MachineCapabilities.items(event, type, crusher -> crusher.items); + } + + /** Registers the interrupt button's handler and the screen; called once at startup. */ + static void install() { + MaterialExtensions.GLOBAL + .events() + .registerCallback( + INTERRUPT, + MaterialEventTypes.GUI_CLICK, + click -> ((Crusher) click.menu().blockEntity).toggleInterrupt()); + String xml = + """ + + + + + + + + + +