diff --git a/core/build.gradle b/core/build.gradle
index 98ead9f95..94a5049b1 100644
--- a/core/build.gradle
+++ b/core/build.gradle
@@ -18,8 +18,15 @@ neoForge {
}
}
+ mods {
+ "${mod_id}" {
+ sourceSet(sourceSets.main)
+ }
+ }
+
unitTest {
enable()
+ testedMod = mods.getByName(mod_id)
}
}
@@ -44,6 +51,8 @@ dependencies {
compileOnly("cc.tweaked:cc-tweaked-${mc_version}-forge-api:${cct_version}")
runtimeOnly("cc.tweaked:cc-tweaked-${mc_version}-forge:${cct_version}")
+ testRuntimeOnly project(":api") // api is compileOnly (JarJar'd into jar), but needed at test runtime since we run from source classes
+
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
}
diff --git a/core/src/main/java/mrtjp/projectred/core/inventory/OverlayContainer.java b/core/src/main/java/mrtjp/projectred/core/inventory/OverlayContainer.java
new file mode 100644
index 000000000..816422a5e
--- /dev/null
+++ b/core/src/main/java/mrtjp/projectred/core/inventory/OverlayContainer.java
@@ -0,0 +1,261 @@
+package mrtjp.projectred.core.inventory;
+
+import net.minecraft.world.Container;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.entity.player.StackedContents;
+import net.minecraft.world.inventory.StackedContentsCompatible;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.ItemStack;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Container that overlays a real container to allow item modification without effecting the underlying container.
+ * The data can then be written back if desired.
+ *
+ * ItemStacks are only copied on access. If underlying is changed before first access, the new change will
+ * reflect in this layer.
+ */
+public class OverlayContainer implements Container, StackedContentsCompatible {
+
+ private final ArrayList items;
+ private final int maxStackSize;
+
+ private OverlayContainer(ArrayList items, int maxStackSize) {
+ this.items = items;
+ this.maxStackSize = maxStackSize;
+ }
+
+ //region Container
+
+ @Override
+ public int getContainerSize() {
+ return items.size();
+ }
+
+ @Override
+ public boolean isEmpty() {
+ for (OverlayItem overlay : items) {
+ if (!overlay.getItemNoCopy().isEmpty()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public ItemStack getItem(int i) {
+ return items.get(i).getItem();
+ }
+
+ @Override
+ public ItemStack removeItem(int i, int amount) {
+ if (amount < 0) {
+ return ItemStack.EMPTY;
+ }
+ var overlay = items.get(i);
+ if (overlay.getItemNoCopy().isEmpty()) {
+ return ItemStack.EMPTY;
+ }
+ var result = overlay.getItem().split(amount);
+ setChanged();
+ return result;
+ }
+
+ @Override
+ public ItemStack removeItemNoUpdate(int i) {
+ var overlay = items.get(i);
+ if (overlay.getItemNoCopy().isEmpty()) {
+ return ItemStack.EMPTY;
+ }
+ var result = overlay.getItem();
+ overlay.setItem(ItemStack.EMPTY);
+ return result;
+ }
+
+ @Override
+ public void setItem(int i, ItemStack stack) {
+ var overlay = items.get(i);
+ overlay.setItem(stack);
+ stack.limitSize(getMaxStackSize(stack));
+ }
+
+ @Override
+ public void setChanged() {
+ }
+
+ @Override
+ public boolean stillValid(Player player) {
+ return false;
+ }
+
+ @Override
+ public void clearContent() {
+ for (OverlayItem overlay : items) {
+ overlay.setItem(ItemStack.EMPTY);
+ }
+ setChanged();
+ }
+
+ @Override
+ public int getMaxStackSize() {
+ return maxStackSize;
+ }
+
+ // Re-implement default to prevent unnecessary copy
+ @Override
+ public int countItem(Item item) {
+ int result = 0;
+ for (OverlayItem overlay : items) {
+ var stack = overlay.getItemNoCopy();
+ if (stack.getItem().equals(item)) {
+ result += stack.getCount();
+ }
+ }
+ return result;
+ }
+ //endregion
+
+ //region StackedContentsCompatible
+ @Override
+ public void fillStackedContents(StackedContents contents) {
+ for (OverlayItem overlay : items) {
+ contents.accountStack(overlay.getItemNoCopy());
+ }
+ }
+ //endregion
+
+ //region Overlay utils
+
+ /**
+ * Drop any changes and go back to underlying container's state
+ */
+ public void clearChanges() {
+ for (OverlayItem overlay : items) {
+ overlay.clear();
+ }
+ }
+
+ /**
+ * Write any changes back to the underlying containers.
+ */
+ public void commitChanges() {
+ for (OverlayItem overlay : items) {
+ overlay.commit();
+ }
+ }
+
+ /**
+ * Force-copies all items into this layer even if unchanged.
+ */
+ public void copyUp() {
+ for (OverlayItem overlay : items) {
+ overlay.getItem();
+ }
+ }
+
+ /**
+ * Create another overlay layer.
+ *
+ * @return An overlay on top of this overlay
+ */
+ public OverlayContainer newLayer() {
+ return new Builder().addItems(this).build();
+ }
+
+ public static OverlayContainer.Builder builder() {
+ return new Builder();
+ }
+
+ public static OverlayContainer of(Container container) {
+ return new Builder().addItems(container).build();
+ }
+ //endregion
+
+ public static class Builder {
+
+ private ArrayList items = new ArrayList<>();
+ private Set maxStackSizes = new HashSet<>();
+
+ private Builder() {
+ }
+
+ public Builder addItems(Container container) {
+ return addItems(container, 0, container.getContainerSize());
+ }
+
+ public Builder addItems(Container src, int srcPos, int length) {
+ for (int i = srcPos; i < srcPos + length; i++) {
+ addItem(src, i);
+ }
+ return this;
+ }
+
+ public Builder addItem(Container src, int srcPos) {
+ items.add(new OverlayItem(src, srcPos));
+ maxStackSizes.add(src.getMaxStackSize());
+ return this;
+ }
+
+ public OverlayContainer build() {
+ if (maxStackSizes.size() > 1) {
+ throw new RuntimeException("Cannot overlay containers with different max stack sizes: " + maxStackSizes);
+ }
+ int maxStackSize = maxStackSizes.isEmpty() ? 64 : maxStackSizes.iterator().next();
+ return new OverlayContainer(items, maxStackSize);
+ }
+ }
+
+ private static class OverlayItem {
+
+ private final Container src;
+ private final int srcPos;
+
+ @Nullable
+ private ItemStack item = null;
+
+ public OverlayItem(Container src, int srcPos) {
+ this.src = src;
+ this.srcPos = srcPos;
+ }
+
+ public ItemStack getItem() {
+ // Copy-on-read
+ if (item == null) {
+ item = src.getItem(srcPos).copy();
+ }
+ return item;
+ }
+
+ // INTERNAL USE ONLY
+ public ItemStack getItemNoCopy() {
+ if (item == null) {
+ return src.getItem(srcPos);
+ }
+ return item;
+ }
+
+ public void setItem(ItemStack item) {
+ this.item = item;
+ }
+
+ public void clear() {
+ item = null;
+ }
+
+ public void commit() {
+ if (item != null) {
+ src.setItem(srcPos, item);
+ }
+ }
+
+ @Override
+ public String toString() {
+ // String includes src, srcPos, upper and lower item
+ return String.format("OverlayItem(src=%s, srcPos=%d, lowerItem=%s, upperItem=%s)", src, srcPos, getItemNoCopy(), item);
+ }
+ }
+}
diff --git a/core/src/main/java/mrtjp/projectred/lib/InventoryLib.java b/core/src/main/java/mrtjp/projectred/lib/InventoryLib.java
index 96eb8ad03..54eea1319 100644
--- a/core/src/main/java/mrtjp/projectred/lib/InventoryLib.java
+++ b/core/src/main/java/mrtjp/projectred/lib/InventoryLib.java
@@ -10,6 +10,7 @@
import net.neoforged.neoforge.items.IItemHandler;
import java.util.function.Consumer;
+import java.util.function.Predicate;
public class InventoryLib {
@@ -70,6 +71,30 @@ private static void injectItemStack(Container inventory, ItemStack stack, int st
}
}
+ public static int removeItems(Container inventory, Predicate matchFunc, int amount, boolean reverse) {
+ return removeItems(inventory, matchFunc, amount, 0, inventory.getContainerSize(), reverse);
+ }
+
+ public static int removeItems(Container inventory, Predicate matchFunc, int amount, int startIndex, int endIndex, boolean reverse) {
+ int removed = 0;
+ for (int i = startIndex; i < endIndex; i++) {
+ int index = reverse ? endIndex - i - 1 : i;
+
+ ItemStack stackInSlot = inventory.getItem(index);
+ if (stackInSlot.isEmpty() || !matchFunc.test(stackInSlot)) continue;
+
+ int amountToExtract = Math.min(amount, stackInSlot.getCount());
+ ItemStack taken = inventory.removeItem(index, amountToExtract);
+ if (!taken.isEmpty()) {
+ amount -= taken.getCount();
+ removed += taken.getCount();
+ }
+ if (amount <= 0) break;
+ }
+
+ return removed;
+ }
+
//region Worldly Container utilities
public static boolean injectWorldly(WorldlyContainer container, ItemStack stack, int side, boolean simulate) {
diff --git a/core/src/test/java/mrtjp/projectred/core/inventory/OverlayContainerTest.java b/core/src/test/java/mrtjp/projectred/core/inventory/OverlayContainerTest.java
new file mode 100644
index 000000000..8449350dc
--- /dev/null
+++ b/core/src/test/java/mrtjp/projectred/core/inventory/OverlayContainerTest.java
@@ -0,0 +1,367 @@
+package mrtjp.projectred.core.inventory;
+
+import net.minecraft.world.SimpleContainer;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class OverlayContainerTest {
+
+ //region Read-through / copy-on-read
+
+ @Test
+ void of_readsUnderlyingItems() {
+ SimpleContainer src = new SimpleContainer(2);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+ src.setItem(1, new ItemStack(Items.OAK_LOG, 3));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+
+ assertEquals(Items.STICK, overlay.getItem(0).getItem());
+ assertEquals(5, overlay.getItem(0).getCount());
+ assertEquals(Items.OAK_LOG, overlay.getItem(1).getItem());
+ assertEquals(3, overlay.getItem(1).getCount());
+ }
+
+ @Test
+ void getItem_returnsCopy_notSameReference() {
+ SimpleContainer src = new SimpleContainer(1);
+ ItemStack original = new ItemStack(Items.STICK, 5);
+ src.setItem(0, original);
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ ItemStack fromOverlay = overlay.getItem(0);
+
+ assertNotSame(original, fromOverlay);
+ assertEquals(5, fromOverlay.getCount());
+ }
+
+ @Test
+ void underlyingChange_reflectsBeforeFirstRead() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ // Change underlying before any read
+ src.setItem(0, new ItemStack(Items.OAK_LOG, 3));
+
+ assertEquals(Items.OAK_LOG, overlay.getItem(0).getItem());
+ assertEquals(3, overlay.getItem(0).getCount());
+ }
+
+ @Test
+ void underlyingChange_doesNotReflect_afterFirstRead() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ overlay.getItem(0); // trigger copy
+
+ src.setItem(0, new ItemStack(Items.OAK_LOG, 3)); // change underlying after read
+
+ assertEquals(Items.STICK, overlay.getItem(0).getItem());
+ assertEquals(5, overlay.getItem(0).getCount());
+ }
+
+ //endregion
+
+ //region setItem / isolation
+
+ @Test
+ void setItem_doesNotAffectUnderlying() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ overlay.setItem(0, new ItemStack(Items.OAK_LOG, 10));
+
+ assertEquals(Items.STICK, src.getItem(0).getItem());
+ assertEquals(5, src.getItem(0).getCount());
+ }
+
+ @Test
+ void setItem_visibleOnOverlay() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ overlay.setItem(0, new ItemStack(Items.OAK_LOG, 10));
+
+ assertEquals(Items.OAK_LOG, overlay.getItem(0).getItem());
+ assertEquals(10, overlay.getItem(0).getCount());
+ }
+
+ //endregion
+
+ //region commitChanges
+
+ @Test
+ void commitChanges_writesBackToUnderlying() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ overlay.setItem(0, new ItemStack(Items.OAK_LOG, 10));
+ overlay.commitChanges();
+
+ assertEquals(Items.OAK_LOG, src.getItem(0).getItem());
+ assertEquals(10, src.getItem(0).getCount());
+ }
+
+ @Test
+ void commitChanges_onlyCommitsModifiedSlots() {
+ SimpleContainer src = new SimpleContainer(2);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+ src.setItem(1, new ItemStack(Items.OAK_LOG, 3));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ overlay.setItem(0, new ItemStack(Items.DIAMOND, 1)); // only modify slot 0
+ overlay.commitChanges();
+
+ assertEquals(Items.DIAMOND, src.getItem(0).getItem());
+ // slot 1 was never accessed in overlay — should be unchanged
+ assertEquals(Items.OAK_LOG, src.getItem(1).getItem());
+ assertEquals(3, src.getItem(1).getCount());
+ }
+
+ //endregion
+
+ //region clearChanges
+
+ @Test
+ void clearChanges_revertsToUnderlying() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ overlay.setItem(0, new ItemStack(Items.OAK_LOG, 10));
+ overlay.clearChanges();
+
+ assertEquals(Items.STICK, overlay.getItem(0).getItem());
+ assertEquals(5, overlay.getItem(0).getCount());
+ }
+
+ @Test
+ void clearChanges_afterUnderlyingChange_seesNewValue() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ overlay.setItem(0, new ItemStack(Items.OAK_LOG, 10)); // dirty overlay
+
+ src.setItem(0, new ItemStack(Items.DIAMOND, 2)); // change underlying
+ overlay.clearChanges(); // reset — overlay should re-read from src
+
+ assertEquals(Items.DIAMOND, overlay.getItem(0).getItem());
+ assertEquals(2, overlay.getItem(0).getCount());
+ }
+
+ //endregion
+
+ //region Other Container methods
+
+ @Test
+ void clearContent_clearsOverlayNotUnderlying() {
+ SimpleContainer src = new SimpleContainer(2);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+ src.setItem(1, new ItemStack(Items.OAK_LOG, 3));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ overlay.clearContent();
+
+ assertTrue(overlay.isEmpty());
+ assertEquals(Items.STICK, src.getItem(0).getItem());
+ assertEquals(Items.OAK_LOG, src.getItem(1).getItem());
+ }
+
+ @Test
+ void removeItem_splitsStack_inOverlay() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 10));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ ItemStack removed = overlay.removeItem(0, 3);
+
+ assertEquals(3, removed.getCount());
+ assertEquals(7, overlay.getItem(0).getCount());
+ assertEquals(10, src.getItem(0).getCount()); // underlying unchanged
+ }
+
+ @Test
+ void removeItemNoUpdate_clearsSlot() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 10));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ ItemStack removed = overlay.removeItemNoUpdate(0);
+
+ assertEquals(10, removed.getCount());
+ assertTrue(overlay.getItem(0).isEmpty());
+ assertEquals(10, src.getItem(0).getCount()); // underlying unchanged
+ }
+
+ @Test
+ void isEmpty_false_whenItemsPresent() {
+ SimpleContainer src = new SimpleContainer(2);
+ src.setItem(0, new ItemStack(Items.STICK, 1));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+
+ assertFalse(overlay.isEmpty());
+ }
+
+ @Test
+ void isEmpty_true_afterClearContent() {
+ SimpleContainer src = new SimpleContainer(2);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+ src.setItem(1, new ItemStack(Items.OAK_LOG, 3));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ overlay.clearContent();
+
+ assertTrue(overlay.isEmpty());
+ }
+
+ @Test
+ void countItem_returnsCorrectCount() {
+ SimpleContainer src = new SimpleContainer(3);
+ src.setItem(0, new ItemStack(Items.STICK, 10));
+ src.setItem(1, new ItemStack(Items.STICK, 20));
+ src.setItem(2, new ItemStack(Items.OAK_LOG, 5));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+
+ assertEquals(30, overlay.countItem(Items.STICK));
+ assertEquals(5, overlay.countItem(Items.OAK_LOG));
+ }
+
+ //endregion
+
+ //region Builder
+
+ @Test
+ void builder_addItemsWithRange_onlySelectedSlots() {
+ SimpleContainer src = new SimpleContainer(4);
+ src.setItem(0, new ItemStack(Items.STICK, 1));
+ src.setItem(1, new ItemStack(Items.OAK_LOG, 2));
+ src.setItem(2, new ItemStack(Items.DIAMOND, 3));
+ src.setItem(3, new ItemStack(Items.GOLD_INGOT, 4));
+
+ OverlayContainer overlay = OverlayContainer.builder()
+ .addItems(src, 1, 2) // slots 1 and 2
+ .build();
+
+ assertEquals(2, overlay.getContainerSize());
+ assertEquals(Items.OAK_LOG, overlay.getItem(0).getItem());
+ assertEquals(Items.DIAMOND, overlay.getItem(1).getItem());
+ }
+
+ @Test
+ void builder_multipleContainers_concatenates() {
+ SimpleContainer src1 = new SimpleContainer(2);
+ src1.setItem(0, new ItemStack(Items.STICK, 1));
+ src1.setItem(1, new ItemStack(Items.OAK_LOG, 2));
+
+ SimpleContainer src2 = new SimpleContainer(2);
+ src2.setItem(0, new ItemStack(Items.DIAMOND, 3));
+ src2.setItem(1, new ItemStack(Items.GOLD_INGOT, 4));
+
+ OverlayContainer overlay = OverlayContainer.builder()
+ .addItems(src1)
+ .addItems(src2)
+ .build();
+
+ assertEquals(4, overlay.getContainerSize());
+ assertEquals(Items.STICK, overlay.getItem(0).getItem());
+ assertEquals(Items.OAK_LOG, overlay.getItem(1).getItem());
+ assertEquals(Items.DIAMOND, overlay.getItem(2).getItem());
+ assertEquals(Items.GOLD_INGOT, overlay.getItem(3).getItem());
+ }
+
+ @Test
+ void builder_mixedMaxStackSizes_throws() {
+ SimpleContainer normal = new SimpleContainer(1); // maxStackSize = 64
+ SimpleContainer small = new SimpleContainer(1) {
+ @Override
+ public int getMaxStackSize() {
+ return 16;
+ }
+ };
+
+ assertThrows(RuntimeException.class, () ->
+ OverlayContainer.builder()
+ .addItems(normal)
+ .addItems(small)
+ .build()
+ );
+ }
+
+ @Test
+ void builder_empty_defaultMaxStackSize64() {
+ OverlayContainer overlay = OverlayContainer.builder().build();
+
+ assertEquals(64, overlay.getMaxStackSize());
+ assertEquals(0, overlay.getContainerSize());
+ }
+
+ //endregion
+
+ //region newLayer
+
+ @Test
+ void newLayer_isolatesChanges() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+
+ OverlayContainer layer1 = OverlayContainer.of(src);
+ OverlayContainer layer2 = layer1.newLayer();
+
+ layer2.setItem(0, new ItemStack(Items.OAK_LOG, 3));
+ layer2.commitChanges();
+
+ // layer1 now sees OAK_LOG (layer2 committed into it)
+ assertEquals(Items.OAK_LOG, layer1.getItem(0).getItem());
+ assertEquals(3, layer1.getItem(0).getCount());
+ // underlying src is still STICK
+ assertEquals(Items.STICK, src.getItem(0).getItem());
+ assertEquals(5, src.getItem(0).getCount());
+ }
+
+ @Test
+ void newLayer_commitBoth_writesToUnderlying() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+
+ OverlayContainer layer1 = OverlayContainer.of(src);
+ OverlayContainer layer2 = layer1.newLayer();
+
+ layer2.setItem(0, new ItemStack(Items.OAK_LOG, 3));
+ layer2.commitChanges();
+ layer1.commitChanges();
+
+ assertEquals(Items.OAK_LOG, src.getItem(0).getItem());
+ assertEquals(3, src.getItem(0).getCount());
+ }
+
+ //endregion
+
+ //region copyUp
+
+ @Test
+ void copyUp_isolatesFromSubsequentUnderlyingChanges() {
+ SimpleContainer src = new SimpleContainer(1);
+ src.setItem(0, new ItemStack(Items.STICK, 5));
+
+ OverlayContainer overlay = OverlayContainer.of(src);
+ overlay.copyUp(); // force-copy all items
+
+ src.setItem(0, new ItemStack(Items.OAK_LOG, 3)); // change underlying
+
+ assertEquals(Items.STICK, overlay.getItem(0).getItem());
+ assertEquals(5, overlay.getItem(0).getCount());
+ }
+
+ //endregion
+}
diff --git a/core/src/test/java/mrtjp/projectred/core/inventory/package-info.java b/core/src/test/java/mrtjp/projectred/core/inventory/package-info.java
new file mode 100644
index 000000000..fa78f5d73
--- /dev/null
+++ b/core/src/test/java/mrtjp/projectred/core/inventory/package-info.java
@@ -0,0 +1,9 @@
+@FieldsAreNonnullByDefault
+@MethodsReturnNonnullByDefault
+@ParametersAreNonnullByDefault
+package mrtjp.projectred.core.inventory;
+
+import net.covers1624.quack.annotation.FieldsAreNonnullByDefault;
+import net.covers1624.quack.annotation.MethodsReturnNonnullByDefault;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/core/src/test/java/mrtjp/projectred/lib/InventoryLibTest.java b/core/src/test/java/mrtjp/projectred/lib/InventoryLibTest.java
new file mode 100644
index 000000000..51ba5e219
--- /dev/null
+++ b/core/src/test/java/mrtjp/projectred/lib/InventoryLibTest.java
@@ -0,0 +1,247 @@
+package mrtjp.projectred.lib;
+
+import net.minecraft.core.NonNullList;
+import net.minecraft.world.SimpleContainer;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class InventoryLibTest {
+
+ //region injectItemStack
+
+ @Test
+ void injectItemStack_emptyContainer_fillsSlot0() {
+ SimpleContainer inv = new SimpleContainer(3);
+ ItemStack stack = new ItemStack(Items.STICK, 1);
+ InventoryLib.injectItemStack(inv, stack, false);
+
+ assertEquals(1, inv.getItem(0).getCount());
+ assertTrue(inv.getItem(1).isEmpty());
+ assertTrue(inv.getItem(2).isEmpty());
+ }
+
+ @Test
+ void injectItemStack_mergesIntoExistingPartialStack_beforeFillingEmpty() {
+ SimpleContainer inv = new SimpleContainer(2);
+ inv.setItem(0, new ItemStack(Items.STICK, 30));
+ // slot 1 left empty
+
+ InventoryLib.injectItemStack(inv, new ItemStack(Items.STICK, 10), false);
+
+ assertEquals(40, inv.getItem(0).getCount());
+ assertTrue(inv.getItem(1).isEmpty());
+ }
+
+ @Test
+ void injectItemStack_fillsEmptyWhenNoPartial() {
+ SimpleContainer inv = new SimpleContainer(2);
+ inv.setItem(0, new ItemStack(Items.OAK_LOG, 1));
+
+ InventoryLib.injectItemStack(inv, new ItemStack(Items.STICK, 1), false);
+
+ assertEquals(Items.OAK_LOG, inv.getItem(0).getItem());
+ assertEquals(Items.STICK, inv.getItem(1).getItem());
+ }
+
+ @Test
+ void injectItemStack_splitsAcrossMultipleSlots() {
+ SimpleContainer inv = new SimpleContainer(2);
+ inv.setItem(0, new ItemStack(Items.STICK, 60));
+ inv.setItem(1, new ItemStack(Items.STICK, 60));
+
+ ItemStack stack = new ItemStack(Items.STICK, 10);
+ InventoryLib.injectItemStack(inv, stack, false);
+
+ assertEquals(64, inv.getItem(0).getCount());
+ assertEquals(64, inv.getItem(1).getCount());
+ assertEquals(2, stack.getCount()); // 4+4 consumed, 2 remaining
+ }
+
+ @Test
+ void injectItemStack_respectsMaxStackSize() {
+ SimpleContainer inv = new SimpleContainer(1);
+ inv.setItem(0, new ItemStack(Items.STICK, 63));
+
+ ItemStack stack = new ItemStack(Items.STICK, 10);
+ InventoryLib.injectItemStack(inv, stack, false);
+
+ assertEquals(64, inv.getItem(0).getCount());
+ assertEquals(9, stack.getCount());
+ }
+
+ @Test
+ void injectItemStack_noSpaceAvailable_inputUnchanged() {
+ SimpleContainer inv = new SimpleContainer(2);
+ inv.setItem(0, new ItemStack(Items.IRON_INGOT, 64));
+ inv.setItem(1, new ItemStack(Items.IRON_INGOT, 64));
+
+ ItemStack stack = new ItemStack(Items.STICK, 5);
+ InventoryLib.injectItemStack(inv, stack, false);
+
+ assertEquals(5, stack.getCount());
+ }
+
+ @Test
+ void injectItemStack_reverse_fillsLastSlot() {
+ SimpleContainer inv = new SimpleContainer(3);
+ InventoryLib.injectItemStack(inv, new ItemStack(Items.STICK, 1), true);
+
+ assertTrue(inv.getItem(0).isEmpty());
+ assertTrue(inv.getItem(1).isEmpty());
+ assertEquals(1, inv.getItem(2).getCount());
+ }
+
+ @Test
+ void injectItemStack_reverse_mergesFromBack() {
+ SimpleContainer inv = new SimpleContainer(2);
+ inv.setItem(0, new ItemStack(Items.STICK, 30));
+ inv.setItem(1, new ItemStack(Items.STICK, 30));
+
+ InventoryLib.injectItemStack(inv, new ItemStack(Items.STICK, 10), true);
+
+ assertEquals(30, inv.getItem(0).getCount()); // slot 0 untouched
+ assertEquals(40, inv.getItem(1).getCount()); // merged into last slot first
+ }
+
+ @Test
+ void injectItemStack_range_onlyTouchesSpecifiedRange() {
+ SimpleContainer inv = new SimpleContainer(5);
+
+ InventoryLib.injectItemStack(inv, new ItemStack(Items.STICK, 1), 2, 4, false);
+
+ assertTrue(inv.getItem(0).isEmpty());
+ assertTrue(inv.getItem(1).isEmpty());
+ assertEquals(1, inv.getItem(2).getCount());
+ assertTrue(inv.getItem(4).isEmpty());
+ }
+
+ //endregion
+
+ //region injectAllItemStacks
+
+ @Test
+ void injectAll_allFit_returnsTrue() {
+ SimpleContainer inv = new SimpleContainer(18);
+ NonNullList stacks = NonNullList.of(ItemStack.EMPTY,
+ new ItemStack(Items.STICK, 10),
+ new ItemStack(Items.OAK_LOG, 5));
+
+ boolean result = InventoryLib.injectAllItemStacks(inv, stacks, false);
+
+ assertTrue(result);
+ assertTrue(stacks.get(0).isEmpty());
+ assertTrue(stacks.get(1).isEmpty());
+ }
+
+ @Test
+ void injectAll_oneCantFit_returnsFalse() {
+ SimpleContainer inv = new SimpleContainer(1);
+ inv.setItem(0, new ItemStack(Items.IRON_INGOT, 64));
+
+ NonNullList stacks = NonNullList.of(ItemStack.EMPTY,
+ new ItemStack(Items.STICK, 1),
+ new ItemStack(Items.OAK_LOG, 1));
+
+ boolean result = InventoryLib.injectAllItemStacks(inv, stacks, false);
+
+ assertFalse(result);
+ }
+
+ @Test
+ void injectAll_emptyStackInList_skipped() {
+ SimpleContainer inv = new SimpleContainer(2);
+ NonNullList stacks = NonNullList.of(ItemStack.EMPTY,
+ ItemStack.EMPTY,
+ new ItemStack(Items.STICK, 1));
+
+ boolean result = InventoryLib.injectAllItemStacks(inv, stacks, false);
+
+ assertTrue(result);
+ assertEquals(Items.STICK, inv.getItem(0).getItem());
+ assertTrue(inv.getItem(1).isEmpty());
+ }
+
+ //endregion
+
+ //region removeItems
+
+ @Test
+ void removeItems_removesMatchingFromSingleSlot() {
+ SimpleContainer inv = new SimpleContainer(1);
+ inv.setItem(0, new ItemStack(Items.STICK, 5));
+
+ int removed = InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 3, false);
+
+ assertEquals(3, removed);
+ assertEquals(2, inv.getItem(0).getCount());
+ }
+
+ @Test
+ void removeItems_returnsPartialWhenNotEnough() {
+ SimpleContainer inv = new SimpleContainer(1);
+ inv.setItem(0, new ItemStack(Items.STICK, 2));
+
+ int removed = InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 5, false);
+
+ assertEquals(2, removed);
+ assertTrue(inv.getItem(0).isEmpty());
+ }
+
+ @Test
+ void removeItems_spreadsAcrossMultipleSlots() {
+ SimpleContainer inv = new SimpleContainer(2);
+ inv.setItem(0, new ItemStack(Items.STICK, 3));
+ inv.setItem(1, new ItemStack(Items.STICK, 3));
+
+ int removed = InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 5, false);
+
+ assertEquals(5, removed);
+ assertTrue(inv.getItem(0).isEmpty());
+ assertEquals(1, inv.getItem(1).getCount());
+ }
+
+ @Test
+ void removeItems_predicateFilters_skipsNonMatching() {
+ SimpleContainer inv = new SimpleContainer(2);
+ inv.setItem(0, new ItemStack(Items.OAK_LOG, 5));
+ inv.setItem(1, new ItemStack(Items.STICK, 3));
+
+ int removed = InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 10, false);
+
+ assertEquals(3, removed);
+ assertEquals(5, inv.getItem(0).getCount()); // untouched
+ assertTrue(inv.getItem(1).isEmpty());
+ }
+
+ @Test
+ void removeItems_reverse_removesFromLastSlotFirst() {
+ SimpleContainer inv = new SimpleContainer(2);
+ inv.setItem(0, new ItemStack(Items.STICK, 5));
+ inv.setItem(1, new ItemStack(Items.STICK, 5));
+
+ int removed = InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 3, true);
+
+ assertEquals(3, removed);
+ assertEquals(5, inv.getItem(0).getCount()); // untouched
+ assertEquals(2, inv.getItem(1).getCount()); // removed from here first
+ }
+
+ @Test
+ void removeItems_range_onlyRemovesFromRange() {
+ SimpleContainer inv = new SimpleContainer(5);
+ for (int i = 0; i < 5; i++) inv.setItem(i, new ItemStack(Items.STICK, 5));
+
+ InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 10, 2, 4, false);
+
+ assertEquals(5, inv.getItem(0).getCount()); // outside range
+ assertEquals(5, inv.getItem(1).getCount()); // outside range
+ assertTrue(inv.getItem(2).isEmpty()); // consumed
+ assertTrue(inv.getItem(3).isEmpty()); // consumed
+ assertEquals(5, inv.getItem(4).getCount()); // outside range
+ }
+
+ //endregion
+}
diff --git a/core/src/test/java/mrtjp/projectred/lib/package-info.java b/core/src/test/java/mrtjp/projectred/lib/package-info.java
new file mode 100644
index 000000000..90305c1ae
--- /dev/null
+++ b/core/src/test/java/mrtjp/projectred/lib/package-info.java
@@ -0,0 +1,9 @@
+@FieldsAreNonnullByDefault
+@MethodsReturnNonnullByDefault
+@ParametersAreNonnullByDefault
+package mrtjp.projectred.lib;
+
+import net.covers1624.quack.annotation.FieldsAreNonnullByDefault;
+import net.covers1624.quack.annotation.MethodsReturnNonnullByDefault;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/expansion/build.gradle b/expansion/build.gradle
index 9d6967fe4..87c8facff 100644
--- a/expansion/build.gradle
+++ b/expansion/build.gradle
@@ -15,6 +15,17 @@ neoForge {
programArguments.addAll '--mod', mod_id, '--all', '--output', file("src/main/generated").absolutePath, '--existing', file("src/main/resources").absolutePath
}
}
+
+ mods {
+ "${mod_id}" {
+ sourceSet(sourceSets.main)
+ }
+ }
+
+ unitTest {
+ enable()
+ testedMod = mods.getByName(mod_id)
+ }
}
dependencies {
@@ -26,4 +37,14 @@ dependencies {
compileOnly project(":api")
implementation project(":core")
+
+ testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
+ testImplementation 'org.mockito:mockito-core:5.14.2'
+ testImplementation 'org.mockito:mockito-junit-jupiter:5.14.2'
+}
+
+test {
+ useJUnitPlatform()
+ jvmArgs('-Xmx3G', '-Xms1G')
}
diff --git a/expansion/src/main/java/mrtjp/projectred/expansion/CraftingHelper.java b/expansion/src/main/java/mrtjp/projectred/expansion/CraftingHelper.java
index 73935d978..c9bf9f144 100644
--- a/expansion/src/main/java/mrtjp/projectred/expansion/CraftingHelper.java
+++ b/expansion/src/main/java/mrtjp/projectred/expansion/CraftingHelper.java
@@ -1,11 +1,10 @@
package mrtjp.projectred.expansion;
import mrtjp.projectred.core.inventory.BaseContainer;
+import mrtjp.projectred.core.inventory.OverlayContainer;
import mrtjp.projectred.lib.InventoryLib;
-import net.covers1624.quack.util.LazyValue;
import net.minecraft.core.NonNullList;
import net.minecraft.world.Container;
-import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.CraftingContainer;
import net.minecraft.world.inventory.ResultContainer;
@@ -18,7 +17,6 @@
import net.neoforged.neoforge.common.CommonHooks;
import javax.annotation.Nullable;
-import java.util.function.Predicate;
public class CraftingHelper {
@@ -52,18 +50,30 @@ public NonNullList getItems() {
private @Nullable RecipeHolder recipe = null;
private CraftingInput.Positioned posCraftingInput = CraftingInput.Positioned.EMPTY;
private CraftingResult result = CraftingResult.EMPTY;
+ private boolean canFitResultsIntoSource = false;
public CraftingHelper(InventorySource inputSource) {
this.inputSource = inputSource;
}
//region Inventory events
+
+ /**
+ * Clears internal state such as located recipe, crafting result, etc.
+ */
public void clear() {
- recipe = null;
craftingInventory.clearContent();
+ craftResultInventory.clearContent();
+ recipe = null;
posCraftingInput = CraftingInput.Positioned.EMPTY;
+ result = CraftingResult.EMPTY;
+ canFitResultsIntoSource = false;
}
+ /**
+ * Refreshes recipe from crafting matrix and re-calculates feasibility of
+ * crafting the recipe output.
+ */
public void onInventoryChanged() {
loadInputs();
loadRecipe();
@@ -81,7 +91,7 @@ public ResultContainer getCraftResultInventory() {
}
//region
- public void loadInputs() {
+ private void loadInputs() {
Container craftingMatrix = inputSource.getCraftingMatrix();
// Copy recipe matrix to internal Crafting Inventory
for (int i = 0; i < 9; i++) {
@@ -90,57 +100,110 @@ public void loadInputs() {
posCraftingInput = craftingInventory.asPositionedCraftInput();
}
- public void loadRecipe() {
+ private void loadRecipe() {
recipe = inputSource.getWorld().getRecipeManager()
.getRecipeFor(RecipeType.CRAFTING, posCraftingInput.input(), inputSource.getWorld()).orElse(null);
craftResultInventory.setItem(0, recipe == null ? ItemStack.EMPTY : recipe.value().assemble(posCraftingInput.input(), inputSource.getWorld().registryAccess()));
}
- public void loadOutput() {
+ private void loadOutput() {
+ OverlayContainer overlay = createAvailableStorageOverlay();
+ result = craftFromSource(overlay, null);
- result = craftFromStorageOrMatrix(true);
+ if (result.isCraftable()) {
+ NonNullList allResults = result.getOutputAndRemaining();
+ canFitResultsIntoSource = InventoryLib.injectAllItemStacks(overlay, allResults, true);
+ }
}
+ //region Public interface
+
+ /**
+ * Check if crafting matrix holds valid recipe ingredients.
+ *
+ * Refreshed on {@link #onInventoryChanged()}
+ *
+ * @return True if matrix matches recipe
+ */
public boolean hasRecipe() {
return recipe != null;
}
- public ItemStack getRecipeOutout() {
+ /**
+ * Returns output item of the current recipe in the crafting matrix
+ *
+ * Refreshed on {@link #onInventoryChanged()}
+ *
+ * @return The recipe output item
+ */
+ public ItemStack getRecipeOutput() {
return craftResultInventory.getItem(0);
}
+ /**
+ * Checks if the crafting ingredient sources contains enough ingredients to craft the current recipe.
+ *
+ * Refreshed on {@link #onInventoryChanged()}
+ *
+ * @return True if crafting is possible
+ */
public boolean canTake() {
return result.isCraftable();
}
+ /**
+ * Checks if sources contain the necessary ingredients to craft, and then also has the space to
+ * take in the results and remaining items post-craft.
+ *
+ * Refreshed on {@link #onInventoryChanged()}
+ *
+ * @return True if crafting and storing is possible
+ */
public boolean canTakeIntoStorage() {
- return canTake() && result.canStorageAcceptResults();
+ return canTake() && canFitResultsIntoSource;
}
+ /**
+ * A 9-bit mask representing slots of the 3x3 matrix. Bits are high if ingredient is missing.
+ *
+ * Refreshed on {@link #onInventoryChanged()}
+ *
+ * @return Missing ingredient mask
+ */
public int getMissingIngredientMask() {
return result.missingIngredientMask;
}
+ /**
+ * Executes a player-based craft, typically from an output slot's onTake() method. This will consume ingredients from the source
+ * containers.
+ *
+ * Contract:
+ * - Will succeed and return true if canTake() is true
+ * - Source containers left unaltered on failure
+ *
+ * @param player The crafting player
+ * @param leaveRemainingInGrid If remaining items should be left in grid. False returns them to storage.
+ * @return True if crafting was successful (ingredients consumed, remaining stored or dropped)
+ */
public boolean onCraftedByPlayer(Player player, boolean leaveRemainingInGrid) {
if (recipe == null) return false;
- CraftingResult result = craftFromStorageOrMatrix(false);
+ // Attempt to consume ingredients and craft
+ OverlayContainer overlay = createAvailableStorageOverlay();
+ CraftingResult result = craftFromSource(overlay, player);
+ if (!result.isCraftable()) return false;
- if (!result.isCraftable()) {
- return false;
- }
-
- // Re-obtain remaining items in case "setCraftingPlayer" changes remaining items
- CommonHooks.setCraftingPlayer(player);
- NonNullList remainingStacks = recipe.value().getRemainingItems(posCraftingInput.input()); // Skip re-searching for recipe, should be ok
- CommonHooks.setCraftingPlayer(null);
+ // Crafting successful. Finalize removal of ingredients
+ overlay.commitChanges();
+ // Put remaining items back
Container craftingGird = inputSource.getCraftingMatrix();
Container storage = inputSource.getStorage();
- for (int i = 0; i < remainingStacks.size(); i++) {
- ItemStack remaining = remainingStacks.get(i);
+ for (int i = 0; i < result.getRemainingItems().size(); i++) {
+ ItemStack remaining = result.getRemainingItems().get(i);
if (remaining.isEmpty()) continue;
// If allowed, leave remaining in crafting grid just like Vanilla crafting bench
@@ -159,33 +222,40 @@ public boolean onCraftedByPlayer(Player player, boolean leaveRemainingInGrid) {
return true;
}
+ /**
+ * Crafts the recipe and puts result and all remaining items back into storage container.
+ *
+ * Contracts:
+ * - Will succeed and return true if canTakeIntoStorage is true
+ * - Source containers left unaltered on failure
+ * - Items can be consumed from matrix if enabled, but result and remaining items will NEVER go back to matrix
+ *
+ * @return True if successful
+ */
public boolean onCraftedIntoStorage() {
-
- CraftingResult result = craftFromStorage(false);
-
- if (!result.isCraftable() || !result.canFitResultsIntoStorage()) return false;
-
- NonNullList allResults = result.getCopyOfAllResults();
- InventoryLib.injectAllItemStacks(inputSource.getStorage(), allResults, true);
-
- return true;
- }
-
- private CraftingResult craftFromStorageOrMatrix(boolean simulate) {
- CraftingResult result = craftFromStorage(simulate);
- if (!result.isCraftable() && inputSource.canConsumeFromCraftingMatrix()) {
- // TODO maybe merge the missingIngredientMasks of these two results?
- result = craftFromSource(inputSource.getCraftingMatrix(), simulate);
+ // Create overlay and attempt to consume ingredients
+ OverlayContainer overlay = createAvailableStorageOverlay();
+ CraftingResult result = craftFromSource(overlay, null);
+ if (!result.isCraftable()) return false;
+
+ // Try to store result items back into storage after ingredients are consumed
+ NonNullList allResults = result.getOutputAndRemaining();
+ // Note: This directly assumes first X slots are storage (See createAvailableStorageOverlay)
+ int storageSize = inputSource.getStorage().getContainerSize();
+ boolean fits = InventoryLib.injectAllItemStacks(overlay, allResults, 0, storageSize, true);
+
+ // Commit if everything fits
+ if (fits) {
+ overlay.commitChanges();
+ return true;
}
- // TODO Hybrid craft that consumes from both sources instead of one or the other?
- return result;
- }
- private CraftingResult craftFromStorage(boolean simulate) {
- return craftFromSource(inputSource.getStorage(), simulate);
+ return false;
}
+ //endregion
- private CraftingResult craftFromSource(Container source, boolean simulate) {
+ //region Utils
+ private CraftingResult craftFromSource(Container source, @Nullable Player player) {
if (recipe == null) return CraftingResult.EMPTY;
if (!recipe.value().matches(posCraftingInput.input(), inputSource.getWorld())) return CraftingResult.EMPTY;
@@ -193,10 +263,6 @@ private CraftingResult craftFromSource(Container source, boolean simulate) {
ItemStack result = recipe.value().assemble(posCraftingInput.input(), inputSource.getWorld().registryAccess());
if (result.isEmpty()) return CraftingResult.EMPTY;
- if (simulate) {
- source = copyInventory(source);
- }
-
// Try to consume all ingredients
int missingIngredientMask = 0;
for (int i = 0; i < 9; i++) {
@@ -204,7 +270,7 @@ private CraftingResult craftFromSource(Container source, boolean simulate) {
ItemStack previousInput = craftingInventory.getItem(slot);
if (previousInput.isEmpty()) continue;
- boolean isPresent = consumeIngredient(source, 0, input -> {
+ int removed = InventoryLib.removeItems(source, input -> {
// Candidate ingredient must be same item
if (!ItemStack.isSameItem(input, previousInput)) return false;
@@ -217,9 +283,9 @@ private CraftingResult craftFromSource(Container source, boolean simulate) {
craftingInventory.setItem(slot, previousInput);
return canStillCraft;
- });
+ }, 1, false);
- if (!isPresent) {
+ if (removed == 0) {
missingIngredientMask |= 1 << i;
}
}
@@ -228,63 +294,50 @@ private CraftingResult craftFromSource(Container source, boolean simulate) {
return CraftingResult.missingIngredients(missingIngredientMask);
}
- return new CraftingResult(result, recipe.value().getRemainingItems(posCraftingInput.input()), 0, simulate ? source : copyInventory(source));
- }
-
- private boolean consumeIngredient(Container storage, int startIndex, Predicate matchFunc) {
-
- int i = startIndex;
- do {
- ItemStack stack = storage.getItem(i);
- if (!stack.isEmpty() && matchFunc.test(stack)) {
- ItemStack taken = storage.removeItem(i, 1);
- if (!taken.isEmpty()) {
- return true;
- }
- }
- i = (i + 1) % storage.getContainerSize();
- } while (i != startIndex);
+ // Obtain remaining items using the crafting player hook if player object was provided.
+ // (See ResultSlot#onTake(Player, ItemStack))
+ //noinspection DataFlowIssue
+ CommonHooks.setCraftingPlayer(player);
+ NonNullList remainingStacks = recipe.value().getRemainingItems(posCraftingInput.input()); // Skip re-searching for recipe, should be ok
+ //noinspection DataFlowIssue
+ CommonHooks.setCraftingPlayer(null);
- return false;
+ return CraftingResult.success(result, remainingStacks);
}
- private static Container copyInventory(Container inventory) {
- //TODO create more accurate copy
- SimpleContainer copy = new SimpleContainer(inventory.getContainerSize());
- for (int i = 0; i < inventory.getContainerSize(); i++) {
- copy.setItem(i, inventory.getItem(i).copy());
+ private OverlayContainer createAvailableStorageOverlay() {
+ var builder = OverlayContainer.builder()
+ .addItems(inputSource.getStorage());
+ if (inputSource.canConsumeFromCraftingMatrix()) {
+ builder.addItems(inputSource.getCraftingMatrix());
}
- return copy;
+
+ return builder.build();
}
+ //endregion
+ /**
+ * Holds result of a crafting attempt. Internal.
+ */
private static final class CraftingResult {
- private static final CraftingResult EMPTY = new CraftingResult(ItemStack.EMPTY, NonNullList.create(), 0, null);
+ private static final CraftingResult EMPTY = new CraftingResult(ItemStack.EMPTY, NonNullList.create(), 0);
- public final ItemStack outputStack;
- public final NonNullList remainingItems;
- public final int missingIngredientMask;
- public final @Nullable Container remainingStorage;
+ private final ItemStack outputStack;
+ private final NonNullList remainingItems;
+ private final int missingIngredientMask;
- private final LazyValue canStorageAcceptResults = new LazyValue<>(this::canFitResultsIntoStorage);
-
- public CraftingResult(ItemStack outputStack, NonNullList remainingItems, int missingIngredientMask, @Nullable Container remainingStorage) {
+ private CraftingResult(ItemStack outputStack, NonNullList remainingItems, int missingIngredientMask) {
this.outputStack = outputStack;
this.remainingItems = remainingItems;
this.missingIngredientMask = missingIngredientMask;
- this.remainingStorage = remainingStorage;
}
public boolean isCraftable() {
return !outputStack.isEmpty() && missingIngredientMask == 0;
}
- public boolean canStorageAcceptResults() {
- return canStorageAcceptResults.get();
- }
-
- public NonNullList getCopyOfAllResults() {
-
+ public NonNullList getOutputAndRemaining() {
NonNullList allResults = NonNullList.withSize(remainingItems.size() + 1, ItemStack.EMPTY);
int i = 0;
allResults.set(i++, outputStack.copy());
@@ -295,17 +348,27 @@ public NonNullList getCopyOfAllResults() {
return allResults;
}
- private boolean canFitResultsIntoStorage() {
- assert remainingStorage != null;
- Container storage = copyInventory(remainingStorage); // Don't mutate original list
- return InventoryLib.injectAllItemStacks(storage, getCopyOfAllResults(), true);
+ public NonNullList getRemainingItems() {
+ NonNullList copy = NonNullList.withSize(remainingItems.size(), ItemStack.EMPTY);
+ for (int i = 0; i < remainingItems.size(); i++) {
+ copy.set(i, remainingItems.get(i).copy());
+ }
+ return copy;
}
public static CraftingResult missingIngredients(int missingIngredientMask) {
- return new CraftingResult(ItemStack.EMPTY, NonNullList.create(), missingIngredientMask, null);
+ return new CraftingResult(ItemStack.EMPTY, NonNullList.create(), missingIngredientMask);
+ }
+
+ public static CraftingResult success(ItemStack outputStack, NonNullList remainingItems) {
+ return new CraftingResult(outputStack, remainingItems, 0);
}
}
+ /**
+ * The holder of a CraftingHelper. Provides access to required objects such as crafting matrix,
+ * storage container, and level. Also provides some configurations.
+ */
public interface InventorySource {
Container getCraftingMatrix();
diff --git a/expansion/src/main/java/mrtjp/projectred/expansion/tile/ProjectBenchBlockEntity.java b/expansion/src/main/java/mrtjp/projectred/expansion/tile/ProjectBenchBlockEntity.java
index 8be8ccb6f..3fe2b109d 100644
--- a/expansion/src/main/java/mrtjp/projectred/expansion/tile/ProjectBenchBlockEntity.java
+++ b/expansion/src/main/java/mrtjp/projectred/expansion/tile/ProjectBenchBlockEntity.java
@@ -196,7 +196,7 @@ private void writePlan() {
if (craftingHelper.hasRecipe() && !isPlanRecipe) {
ItemStack planStack = planInventory.getItem(0);
- ItemStack result = craftingHelper.getRecipeOutout();
+ ItemStack result = craftingHelper.getRecipeOutput();
if (!planStack.isEmpty() && !result.isEmpty()) {
ItemStack[] inputs = new ItemStack[9];
diff --git a/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperScenarioTest.java b/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperScenarioTest.java
new file mode 100644
index 000000000..b81ddab0c
--- /dev/null
+++ b/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperScenarioTest.java
@@ -0,0 +1,415 @@
+package mrtjp.projectred.expansion;
+
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.Container;
+import net.minecraft.world.SimpleContainer;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.crafting.*;
+import net.minecraft.world.level.ItemLike;
+import net.minecraft.world.level.Level;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.annotation.Nullable;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class CraftingHelperScenarioTest {
+
+ @Mock Level level;
+ @Mock RecipeManager recipeManager;
+ @Mock RegistryAccess registryAccess;
+ @Mock Player player;
+
+ //region Scenario model + fluent builder
+
+ enum CraftAction { INTO_STORAGE, BY_PLAYER }
+
+ record Scenario(
+ int matrixSize,
+ int storageSize,
+ CraftingRecipe recipe,
+ Map matrixSetup,
+ Map storageSetup,
+ boolean canConsumeFromMatrix,
+ CraftAction action,
+ boolean leaveRemainingInGrid,
+ boolean expectedSuccess,
+ ItemStack expectedTakenOutput,
+ Map expectedMatrixSlots,
+ Map expectedStorageSlots
+ ) {}
+
+ static final class Builder {
+ private int matrixSize = 9;
+ private int storageSize = 18;
+ @Nullable private CraftingRecipe recipe;
+ private final Map matrixSetup = new LinkedHashMap<>();
+ private final Map storageSetup = new LinkedHashMap<>();
+ private boolean canConsumeFromMatrix = false;
+ private CraftAction action = CraftAction.INTO_STORAGE;
+ private boolean leaveRemainingInGrid = false;
+ private boolean expectedSuccess = true;
+ private ItemStack expectedTakenOutput = ItemStack.EMPTY;
+ private final Map expectedMatrixSlots = new LinkedHashMap<>();
+ private final Map expectedStorageSlots = new LinkedHashMap<>();
+
+ // Starting conditions and settings
+ Builder recipe(CraftingRecipe recipe) { this.recipe = recipe; return this; }
+ Builder matrix(int slot, Item item) { strictPut(matrixSetup, slot, new ItemStack(item)); return this; }
+ Builder matrix(int slot, ItemStack s) { strictPut(matrixSetup, slot, s.copy()); return this; }
+ Builder storage(int slot, Item item) { strictPut(storageSetup, slot, new ItemStack(item)); return this; }
+ Builder storage(int slot, ItemStack s) { strictPut(storageSetup, slot, s.copy()); return this; }
+ /** Pack every unset storage slot with iron ingots (simulates full storage). */
+ Builder fillEmptyStorage(ItemStack stack) { for (int i = 0; i < storageSize; i++) storageSetup.computeIfAbsent(i, n -> stack.copy()); return this; }
+ Builder canConsumeFromMatrix() { canConsumeFromMatrix = true; return this; }
+ Builder craftIntoStorage() { action = CraftAction.INTO_STORAGE; return this; }
+ Builder craftByPlayer() { return craftByPlayer(false); }
+ Builder craftByPlayer(boolean leaveInGrid) { action = CraftAction.BY_PLAYER; leaveRemainingInGrid = leaveInGrid; return this; }
+
+ // Expectations
+ Builder expectFail() { expectedSuccess = false; return this; }
+ Builder expectTakenOutput(ItemStack s) { expectedTakenOutput = s.copy(); return this; }
+ Builder expectMatrixSlot(int slot, ItemLike s, int count) { return expectMatrixSlot(slot, new ItemStack(s, count)); }
+ Builder expectMatrixSlotEmpty(int slot) { return expectMatrixSlot(slot, ItemStack.EMPTY); }
+ Builder expectMatrixSlot(int slot, ItemStack s) { strictPut(expectedMatrixSlots, slot, s.copy()); return this; }
+ Builder expectMatrixSlotRange(int a, int b, ItemStack s) { for (int i = a; i <= b; i++) expectMatrixSlot(i, s); return this; }
+ Builder expectMatrixEmpty() { for (int i = 0; i < matrixSize; i++) expectMatrixSlotEmpty(i); return this; }
+ Builder expectStorageSlot(int slot, ItemLike s, int count) { return expectStorageSlot(slot, new ItemStack(s, count)); }
+ Builder expectStorageSlotEmpty(int slot) { return expectStorageSlot(slot, ItemStack.EMPTY); }
+ Builder expectStorageSlot(int slot, ItemStack s) { strictPut(expectedStorageSlots, slot, s.copy()); return this; }
+ Builder expectStorageSlotRange(int a, int b, ItemStack s) { for (int i = a; i <= b; i++) expectStorageSlot(i, s); return this; }
+ Builder expectStorageEmpty() { for (int i = 0; i < storageSize; i++) expectStorageSlotEmpty(i); return this; }
+
+ Builder expectStorageTotal(Item item, int count) { return this; }
+
+ private void strictPut(Map dest, K key, V value) {
+ assertFalse(dest.containsKey(key), "Duplicate key found: " + key);
+ dest.put(key, value);
+ }
+
+ private void assertNonEmpty(Map map, String message) {
+ for (var entry : map.entrySet()) {
+ if (!entry.getValue().isEmpty()) {
+ return;
+ }
+ }
+ fail(message);
+ }
+
+ Scenario build() {
+ assertNotNull(recipe);
+ return new Scenario(matrixSize, storageSize, recipe,
+ Map.copyOf(matrixSetup), Map.copyOf(storageSetup), canConsumeFromMatrix,
+ action, leaveRemainingInGrid, expectedSuccess,
+ expectedTakenOutput, Map.copyOf(expectedMatrixSlots), Map.copyOf(expectedStorageSlots));
+ }
+ }
+
+ static Builder scenario() { return new Builder(); }
+
+ //endregion
+
+ //region Recipe helpers
+
+ static ShapedRecipe shaped(ItemStack output, Map key, String... pattern) {
+ return new ShapedRecipe("", CraftingBookCategory.MISC, ShapedRecipePattern.of(key, pattern), output);
+ }
+
+ static ShapedRecipe shapedSingle(ItemStack output, Ingredient input) {
+ return shaped(output, Map.of('A', input), "A");
+ }
+
+ //endregion
+
+ //region Auto Crafter tests
+
+ //region Ingredient consumption
+
+ @Test
+ void craftIntoStorage_ingredientConsumedOutputStored() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG)))
+ .matrix(0, Items.OAK_LOG)
+ .storage(0, Items.OAK_LOG)
+ .craftIntoStorage()
+ .expectStorageSlotEmpty(0)
+ .expectStorageSlot(17, Items.OAK_PLANKS, 4) // Reverse-written into storage
+ .build());
+ }
+
+ @Test
+ void craftIntoStorage_consumingIngredientsCreatesStorageSpace() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG)))
+ .matrix(0, Items.OAK_LOG)
+ .storage(0, new ItemStack(Items.OAK_LOG, 1)) // Slot available after consuming ingredients
+ .fillEmptyStorage(new ItemStack(Items.STICK))
+ .craftIntoStorage()
+ .expectStorageSlot(0, new ItemStack(Items.OAK_PLANKS, 4)) // Emptied slot gets item
+ .expectStorageSlotRange(1, 17, new ItemStack(Items.STICK)) // Other slots unchanged
+ .build());
+ }
+
+ @Test
+ void craftIntoStorage_consumeFromMultipleSlots() {
+ runScenario(scenario()
+ .recipe(shaped(new ItemStack(Items.CRAFTING_TABLE), Map.of('P', Ingredient.of(Items.OAK_PLANKS)), "PP", "PP"))
+ .matrix(4, Items.OAK_PLANKS).matrix(5, Items.OAK_PLANKS)
+ .matrix(7, Items.OAK_PLANKS).matrix(8, Items.OAK_PLANKS)
+ .storage(0, Items.OAK_PLANKS).storage(1, Items.OAK_PLANKS)
+ .storage(2, Items.OAK_PLANKS).storage(3, Items.OAK_PLANKS)
+ .craftIntoStorage()
+ .expectStorageSlotEmpty(0).expectStorageSlotEmpty(1)
+ .expectStorageSlotEmpty(2).expectStorageSlotEmpty(3)
+ .expectStorageSlot(17, Items.CRAFTING_TABLE, 1)
+ .build());
+ }
+
+ @Test
+ void craftIntoStorage_consumeFromSingleSlot() {
+ runScenario(scenario()
+ .recipe(shaped(new ItemStack(Items.CRAFTING_TABLE), Map.of('P', Ingredient.of(Items.OAK_PLANKS)), "PP", "PP"))
+ .matrix(4, Items.OAK_PLANKS).matrix(5, Items.OAK_PLANKS)
+ .matrix(7, Items.OAK_PLANKS).matrix(8, Items.OAK_PLANKS)
+ .storage(0, new ItemStack(Items.OAK_PLANKS, 4)) // All ingredients in 1 slot
+ .craftIntoStorage()
+ .expectStorageSlotEmpty(0)
+ .expectStorageSlot(17, Items.CRAFTING_TABLE, 1)
+ .build());
+ }
+
+ @Test
+ void craftIntoStorage_failsWhenIngredientMissing() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG)))
+ .matrix(0, Items.OAK_LOG)
+ // storage intentionally empty
+ .craftIntoStorage()
+ .expectFail()
+ .build());
+ }
+
+ @Test
+ void craftIntoStorage_failsWhenStorageFull() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG)))
+ .matrix(0, Items.OAK_LOG)
+ .storage(0, new ItemStack(Items.OAK_LOG, 2)) // Too many to consume and make empty slot
+ .fillEmptyStorage(new ItemStack(Items.STICK))
+ .craftIntoStorage()
+ .expectFail()
+ .build());
+ }
+
+ //endregion
+
+ //region Matrix consumption
+
+ @Test
+ void craftIntoStorage_consumesFromMatrixWhenEnabled() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG)))
+ .matrix(0, Items.OAK_LOG)
+ // storage empty — ingredient only in matrix
+ .canConsumeFromMatrix()
+ .craftIntoStorage()
+ .expectMatrixEmpty()
+ .expectStorageSlotEmpty(0)
+ .expectStorageSlot(17, Items.OAK_PLANKS, 4)
+ .build());
+ }
+
+ //endregion
+
+ //region Remaining items
+
+ @Test
+ void craftIntoStorage_remainingItemStoredAlongsideOutput() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.CAKE), Ingredient.of(Items.WATER_BUCKET)))
+ .matrix(0, Items.WATER_BUCKET)
+ .storage(0, Items.WATER_BUCKET)
+ .craftIntoStorage()
+ .expectStorageSlotEmpty(0)
+ .expectStorageSlot(17, Items.CAKE, 1)
+ .expectStorageSlot(16, Items.BUCKET, 1)
+ .build());
+ }
+
+ //endregion
+
+ //endregion
+
+ //region Project Bench tests
+
+ //region Ingredient consumption
+
+ //CASE: Some ingredients in storage but some in matrix
+
+ @Test
+ void craftByPlayer_ingredientConsumedOnSuccess() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG)))
+ .matrix(0, Items.OAK_LOG)
+ .storage(0, Items.OAK_LOG)
+ .craftByPlayer()
+ .expectTakenOutput(new ItemStack(Items.OAK_PLANKS, 4))
+ .expectStorageEmpty()
+ .build());
+ }
+
+ //endregion
+
+ //region Remaining items
+
+ @Test
+ void craftByPlayer_remainingSentToStorageWhenLeaveRemainingFalse() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.CAKE), Ingredient.of(Items.WATER_BUCKET)))
+ .matrix(0, Items.WATER_BUCKET)
+ .storage(0, Items.WATER_BUCKET)
+ .craftByPlayer(false)
+ .expectStorageSlotEmpty(0)
+ .expectStorageSlot(17, Items.BUCKET, 1)
+ .expectTakenOutput(new ItemStack(Items.CAKE, 1))
+ .build());
+ }
+
+ @Test
+ void craftByPlayer_remainingLeftInMatrixWhenConsumingFromMatrix() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.CAKE), Ingredient.of(Items.WATER_BUCKET)))
+ .matrix(0, Items.WATER_BUCKET)
+ // storage empty; consume from matrix so slot 0 is freed for the remaining bucket
+ .canConsumeFromMatrix()
+ .craftByPlayer(true)
+ .expectMatrixSlot(0, new ItemStack(Items.BUCKET))
+ .expectStorageEmpty()
+ .expectTakenOutput(new ItemStack(Items.CAKE, 1))
+ .build());
+ }
+
+ @Test
+ void craftByPlayer_remainingNotLeftInMatrixWhenNotConsumingFromMatrix() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.CAKE), Ingredient.of(Items.WATER_BUCKET)))
+ .matrix(0, Items.WATER_BUCKET)
+ .storage(0, Items.WATER_BUCKET)
+ .craftByPlayer(true) // set leave in matrix
+ .expectStorageSlotEmpty(0)
+ .expectStorageSlot(17, Items.BUCKET, 1) // Matrix still has original bucket, so falls back here
+ .expectTakenOutput(new ItemStack(Items.CAKE, 1))
+ .build());
+ }
+
+ @Test
+ void craftByPlayer_remainingFallsBackToPlayerWhenStorageFull() {
+ runScenario(scenario()
+ .recipe(shapedSingle(new ItemStack(Items.CAKE), Ingredient.of(Items.WATER_BUCKET)))
+ .matrix(0, Items.WATER_BUCKET)
+ .canConsumeFromMatrix()
+ .fillEmptyStorage(new ItemStack(Items.STICK))
+ .craftByPlayer(false)
+ .expectMatrixEmpty()
+ .expectTakenOutput(new ItemStack(Items.CAKE, 1))
+ .build());
+
+ //TODO test this thru scenario
+ verify(player).addItem(argThat(s -> s.getItem() == Items.BUCKET));
+ }
+
+ //endregion
+
+ //endregion
+
+ //region Scenario runner
+
+ private void runScenario(Scenario s) {
+ // Set up containers
+ SimpleContainer matrix = new SimpleContainer(s.matrixSize());
+ SimpleContainer storage = new SimpleContainer(s.storageSize());
+ s.matrixSetup().forEach((slot, stack) -> matrix.setItem(slot, stack.copy()));
+ s.storageSetup().forEach((slot, stack) -> storage.setItem(slot, stack.copy()));
+
+ // Wire up mocks; recipeManager delegates matching to the recipe
+ RecipeHolder recipeHolder = new RecipeHolder<>(
+ ResourceLocation.parse("test:recipe"), s.recipe());
+
+ lenient().when(level.getRecipeManager()).thenReturn(recipeManager);
+ lenient().when(level.registryAccess()).thenReturn(registryAccess);
+ lenient().when(recipeManager.getRecipeFor(eq(RecipeType.CRAFTING), any(CraftingInput.class), any()))
+ .thenAnswer(inv -> s.recipe().matches(inv.getArgument(1), level)
+ ? Optional.of(recipeHolder) : Optional.empty());
+
+ CraftingHelper helper = new CraftingHelper(new CraftingHelper.InventorySource() {
+ @Override public Container getCraftingMatrix() { return matrix; }
+ @Override public Container getStorage() { return storage; }
+ @Override public boolean canConsumeFromCraftingMatrix() { return s.canConsumeFromMatrix(); }
+ @Override public Level getWorld() { return level; }
+ });
+ helper.onInventoryChanged();
+
+ boolean testResult = false;
+ boolean result = false;
+
+ switch (s.action) {
+ case INTO_STORAGE -> {
+ testResult = helper.canTakeIntoStorage();
+ result = helper.onCraftedIntoStorage();
+ }
+ case BY_PLAYER -> {
+ testResult = helper.canTake();
+ ItemStack taken = helper.getRecipeOutput();
+ result = helper.onCraftedByPlayer(player, s.leaveRemainingInGrid());
+
+ assertTrue(ItemStack.matches(taken, s.expectedTakenOutput()), "Player taken crafting output does not match expected");
+ }
+ }
+
+ assertEquals(testResult, result, "Craft pre-check result does not match final result");
+ assertEquals(s.expectedSuccess(), result, "craft action return value");
+ assertContainerChanges(matrix, s.matrixSetup(), s.expectedMatrixSlots(), "Invalid matrix changes");
+ assertContainerChanges(storage, s.storageSetup(), s.expectedStorageSlots(), "Invalid storage changes");
+ }
+
+ //endregion
+
+ //region Helpers
+
+ private static void assertContainerContents(Container container, Map map, String containerName) {
+ for (int i = 0; i < container.getContainerSize(); i++) {
+ ItemStack actual = container.getItem(i);
+ ItemStack expected = map.getOrDefault(i, ItemStack.EMPTY);
+ assertTrue(ItemStack.matches(expected, actual),
+ containerName + "[" + i + "] expected " + expected + ", got " + actual);
+ }
+ }
+
+ private static void assertContainerChanges(Container container, Map initial, Map expectedChanges, String message) {
+ // All slots should not change unless explicitly expected
+ for (int i = 0; i < container.getContainerSize(); i++) {
+ ItemStack actual = container.getItem(i);
+ ItemStack expected = expectedChanges.getOrDefault(i, initial.getOrDefault(i, ItemStack.EMPTY));
+ String expectation = expectedChanges.containsKey(i) ? "change to ": "remain ";
+
+ assertTrue(ItemStack.matches(expected, actual),
+ message + ": Slot [" + i + "] expected to " + expectation + expected + ", but got " + actual);
+ }
+ }
+
+ //endregion
+}
diff --git a/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperTest.java b/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperTest.java
new file mode 100644
index 000000000..158b28da4
--- /dev/null
+++ b/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperTest.java
@@ -0,0 +1,406 @@
+package mrtjp.projectred.expansion;
+
+import net.minecraft.core.NonNullList;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.Container;
+import net.minecraft.world.SimpleContainer;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.crafting.CraftingInput;
+import net.minecraft.world.item.crafting.CraftingRecipe;
+import net.minecraft.world.item.crafting.RecipeHolder;
+import net.minecraft.world.item.crafting.RecipeManager;
+import net.minecraft.world.item.crafting.RecipeType;
+import net.minecraft.world.level.Level;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+public class CraftingHelperTest {
+
+ SimpleContainer matrix; // 9 slots — recipe pattern
+ SimpleContainer storage; // 18 slots — ingredient source
+
+ @Mock Level level;
+ @Mock RecipeManager recipeManager;
+ @Mock RegistryAccess registryAccess;
+ @Mock Player player;
+ @Mock CraftingRecipe mockRecipe;
+
+ RecipeHolder recipeHolder;
+ CraftingHelper helper;
+
+ @BeforeEach
+ void setUp() {
+ matrix = new SimpleContainer(9);
+ storage = new SimpleContainer(18);
+
+ recipeHolder = new RecipeHolder<>(ResourceLocation.parse("test:recipe"), mockRecipe);
+
+ lenient().when(level.getRecipeManager()).thenReturn(recipeManager);
+ lenient().when(level.registryAccess()).thenReturn(registryAccess);
+ lenient().when(recipeManager.getRecipeFor(eq(RecipeType.CRAFTING), any(CraftingInput.class), any()))
+ .thenReturn(Optional.of(recipeHolder));
+ lenient().when(mockRecipe.matches(any(CraftingInput.class), any(Level.class))).thenReturn(true);
+ lenient().when(mockRecipe.assemble(any(CraftingInput.class), any())).thenReturn(new ItemStack(Items.STICK, 4));
+ lenient().when(mockRecipe.getRemainingItems(any(CraftingInput.class))).thenReturn(NonNullList.withSize(9, ItemStack.EMPTY));
+
+ helper = makeHelper(false);
+ }
+
+ // Build a helper with configurable canConsumeFromCraftingMatrix
+ private CraftingHelper makeHelper(boolean consumeFromMatrix) {
+ return new CraftingHelper(new CraftingHelper.InventorySource() {
+ @Override public Container getCraftingMatrix() { return matrix; }
+ @Override public Container getStorage() { return storage; }
+ @Override public boolean canConsumeFromCraftingMatrix() { return consumeFromMatrix; }
+ @Override public Level getWorld() { return level; }
+ });
+ }
+
+ // Fill all 9 matrix slots and corresponding storage slots with 1 stick each
+ private void setUpFullMatrixAndStorageWithSticks() {
+ for (int i = 0; i < 9; i++) {
+ matrix.setItem(i, new ItemStack(Items.STICK));
+ storage.setItem(i, new ItemStack(Items.STICK));
+ }
+ }
+
+ //region clear()
+
+ @Test
+ public void testClear_resetsAllState() {
+ setUpFullMatrixAndStorageWithSticks();
+ helper.onInventoryChanged();
+ helper.clear();
+
+ assertFalse(helper.hasRecipe());
+ assertTrue(helper.getRecipeOutput().isEmpty());
+ assertFalse(helper.canTake());
+ assertFalse(helper.canTakeIntoStorage());
+ assertEquals(0, helper.getMissingIngredientMask());
+ assertTrue(helper.getCraftingInventory().isEmpty());
+ assertTrue(helper.getCraftResultInventory().isEmpty());
+ }
+
+ //endregion
+
+ //region onInventoryChanged()
+
+ @Test
+ public void testOnInventoryChanged_copiesMatrixToInternalInventory() {
+ matrix.setItem(3, new ItemStack(Items.STICK));
+ helper.onInventoryChanged();
+
+ assertEquals(Items.STICK, helper.getCraftingInventory().getItem(3).getItem());
+ assertTrue(helper.getCraftingInventory().getItem(0).isEmpty());
+ }
+
+ @Test
+ public void testOnInventoryChanged_noRecipe_stateIsClean() {
+ when(recipeManager.getRecipeFor(any(), any(CraftingInput.class), any()))
+ .thenReturn(Optional.empty());
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ helper.onInventoryChanged();
+
+ assertFalse(helper.hasRecipe());
+ assertTrue(helper.getRecipeOutput().isEmpty());
+ assertFalse(helper.canTake());
+ assertFalse(helper.canTakeIntoStorage());
+ }
+
+ @Test
+ public void testOnInventoryChanged_withRecipe_setsHasRecipeAndOutput() {
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK));
+ helper.onInventoryChanged();
+
+ assertTrue(helper.hasRecipe());
+ assertEquals(Items.STICK, helper.getRecipeOutput().getItem());
+ }
+
+ //endregion
+
+ //region canTake()
+
+ @Test
+ public void testCanTake_trueWhenIngredientInStorage() {
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK));
+ helper.onInventoryChanged();
+
+ assertTrue(helper.canTake());
+ }
+
+ @Test
+ public void testCanTake_falseWhenStorageEmpty() {
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ // storage left empty
+ helper.onInventoryChanged();
+
+ assertFalse(helper.canTake());
+ }
+
+ @Test
+ public void testCanTake_withMatrixConsumption_usesMatrixItems() {
+ helper = makeHelper(true);
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ // storage is EMPTY — ingredient only in matrix
+ helper.onInventoryChanged();
+
+ assertTrue(helper.canTake());
+ }
+
+ @Test
+ public void testCanTake_withoutMatrixConsumption_ignoresMatrixItems() {
+ // default helper — canConsumeFromCraftingMatrix=false
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ // storage is EMPTY
+ helper.onInventoryChanged();
+
+ assertFalse(helper.canTake());
+ }
+
+ //endregion
+
+ //region getMissingIngredientMask()
+
+ @Test
+ public void testMissingIngredientMask_zeroWhenAllPresent() {
+ setUpFullMatrixAndStorageWithSticks();
+ helper.onInventoryChanged();
+
+ assertEquals(0, helper.getMissingIngredientMask());
+ }
+
+ @Test
+ public void testMissingIngredientMask_bit0WhenSlot0Missing() {
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ // storage empty
+ helper.onInventoryChanged();
+
+ assertEquals(0b000000001, helper.getMissingIngredientMask());
+ }
+
+ @Test
+ public void testMissingIngredientMask_multipleBitsForMultipleMissing() {
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ matrix.setItem(1, new ItemStack(Items.STICK));
+ matrix.setItem(2, new ItemStack(Items.STICK));
+ // storage empty
+ helper.onInventoryChanged();
+
+ assertEquals(0b000000111, helper.getMissingIngredientMask());
+ }
+
+ //endregion
+
+ //region canTakeIntoStorage()
+
+ @Test
+ public void testCanTakeIntoStorage_trueWhenStorageHasRoom() {
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK)); // ingredient; slots 1-17 free
+ helper.onInventoryChanged();
+
+ assertTrue(helper.canTakeIntoStorage());
+ }
+
+ @Test
+ public void testCanTakeIntoStorage_falseWhenStorageFull() {
+ // Output = diamond sword (max stack 1) so it can't merge anywhere
+ when(mockRecipe.assemble(any(), any())).thenReturn(new ItemStack(Items.DIAMOND_SWORD));
+
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ // Slot 0 has 2 sticks: consuming 1 leaves 1 stick (slot NOT empty)
+ storage.setItem(0, new ItemStack(Items.STICK, 2));
+ // All other 17 slots packed full
+ for (int i = 1; i < 18; i++) {
+ storage.setItem(i, new ItemStack(Items.IRON_INGOT, 64));
+ }
+ helper.onInventoryChanged();
+
+ // No room for the sword — every slot is occupied and it can't stack with sticks/ingots
+ assertFalse(helper.canTakeIntoStorage());
+ }
+
+ //endregion
+
+ //region onCraftedByPlayer()
+
+ @Test
+ public void testOnCraftedByPlayer_returnsFalseWithNoRecipe() {
+ when(recipeManager.getRecipeFor(any(), any(CraftingInput.class), any()))
+ .thenReturn(Optional.empty());
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK));
+ helper.onInventoryChanged();
+
+ assertFalse(helper.onCraftedByPlayer(player, false));
+ assertEquals(Items.STICK, storage.getItem(0).getItem()); // untouched
+ }
+
+ @Test
+ public void testOnCraftedByPlayer_consumesIngredientFromStorage() {
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK));
+ helper.onInventoryChanged();
+
+ assertTrue(helper.onCraftedByPlayer(player, false));
+ assertTrue(storage.getItem(0).isEmpty());
+ }
+
+ @Test
+ public void testOnCraftedByPlayer_remainingGoesToGrid_whenMatrixConsumed() {
+ helper = makeHelper(true); // matrix items are also consumed
+
+ // Remaining: a bucket appears at crafting-input slot 0
+ NonNullList remaining = NonNullList.withSize(9, ItemStack.EMPTY);
+ remaining.set(0, new ItemStack(Items.BUCKET));
+ when(mockRecipe.getRemainingItems(any())).thenReturn(remaining);
+
+ // Full 3x3 matrix of sticks so CraftingInput is 3x3 at (left=0, top=0) — slot 0 maps to matrix slot 0
+ for (int i = 0; i < 9; i++) matrix.setItem(i, new ItemStack(Items.STICK));
+ // Storage empty; ingredients consumed from matrix via overlay
+ helper.onInventoryChanged();
+
+ assertTrue(helper.onCraftedByPlayer(player, true));
+
+ // After consuming the stick from matrix slot 0, the slot is empty.
+ // The bucket remaining item is then placed back into slot 0.
+ assertEquals(Items.BUCKET, matrix.getItem(0).getItem());
+ }
+
+ @Test
+ public void testOnCraftedByPlayer_remainingGoesToStorage_whenLeaveRemainingFalse() {
+ NonNullList remaining = NonNullList.withSize(9, ItemStack.EMPTY);
+ remaining.set(0, new ItemStack(Items.BUCKET));
+ when(mockRecipe.getRemainingItems(any())).thenReturn(remaining);
+
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK));
+ helper.onInventoryChanged();
+ helper.onCraftedByPlayer(player, false);
+
+ boolean bucketInStorage = false;
+ for (int i = 0; i < 18; i++) {
+ if (storage.getItem(i).getItem() == Items.BUCKET) { bucketInStorage = true; break; }
+ }
+ assertTrue(bucketInStorage);
+ }
+
+ @Test
+ public void testOnCraftedByPlayer_remainingFallsBackToPlayer_whenStorageFull() {
+ NonNullList remaining = NonNullList.withSize(9, ItemStack.EMPTY);
+ remaining.set(0, new ItemStack(Items.DIAMOND_SWORD)); // can't merge into anything
+ when(mockRecipe.getRemainingItems(any())).thenReturn(remaining);
+
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK, 2)); // consume 1 → 1 left, slot not empty
+ for (int i = 1; i < 18; i++) storage.setItem(i, new ItemStack(Items.IRON_INGOT, 64));
+
+ helper.onInventoryChanged();
+ helper.onCraftedByPlayer(player, false);
+
+ verify(player).addItem(argThat(s -> s.getItem() == Items.DIAMOND_SWORD));
+ }
+
+ @Test
+ public void testOnCraftedByPlayer_sourceUnalteredOnFailure() {
+ // Recipe found, but matches() = false → craftFromSource returns EMPTY
+ when(mockRecipe.matches(any(), any())).thenReturn(false);
+
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK));
+ helper.onInventoryChanged();
+
+ assertFalse(helper.onCraftedByPlayer(player, false));
+ assertEquals(1, storage.getItem(0).getCount()); // overlay never committed
+ }
+
+ //endregion
+
+ //region onCraftedIntoStorage()
+
+ @Test
+ public void testOnCraftedIntoStorage_storesOutputInStorage() {
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK)); // ingredient; slots 1-17 free for output
+ helper.onInventoryChanged();
+
+ assertTrue(helper.onCraftedIntoStorage());
+
+ // 4 sticks output injected; original 1-stick ingredient consumed → net = 4 sticks
+ int totalSticks = 0;
+ for (int i = 0; i < 18; i++) {
+ if (storage.getItem(i).getItem() == Items.STICK) {
+ totalSticks += storage.getItem(i).getCount();
+ }
+ }
+ assertEquals(4, totalSticks);
+ }
+
+ @Test
+ public void testOnCraftedIntoStorage_storesRemainingItemsInStorage() {
+ NonNullList remaining = NonNullList.withSize(9, ItemStack.EMPTY);
+ remaining.set(0, new ItemStack(Items.BUCKET));
+ when(mockRecipe.getRemainingItems(any())).thenReturn(remaining);
+
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK));
+ helper.onInventoryChanged();
+
+ assertTrue(helper.onCraftedIntoStorage());
+
+ boolean bucketInStorage = false;
+ for (int i = 0; i < 18; i++) {
+ if (storage.getItem(i).getItem() == Items.BUCKET) { bucketInStorage = true; break; }
+ }
+ assertTrue(bucketInStorage);
+ }
+
+ @Test
+ public void testOnCraftedIntoStorage_returnsFalseWhenNoRoom() {
+ when(mockRecipe.assemble(any(), any())).thenReturn(new ItemStack(Items.DIAMOND_SWORD));
+
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK, 2));
+ for (int i = 1; i < 18; i++) storage.setItem(i, new ItemStack(Items.IRON_INGOT, 64));
+ helper.onInventoryChanged();
+
+ assertFalse(helper.onCraftedIntoStorage());
+ }
+
+ @Test
+ public void testOnCraftedIntoStorage_sourceUnalteredOnFailure() {
+ when(mockRecipe.assemble(any(), any())).thenReturn(new ItemStack(Items.DIAMOND_SWORD));
+
+ matrix.setItem(0, new ItemStack(Items.STICK));
+ storage.setItem(0, new ItemStack(Items.STICK, 2));
+ for (int i = 1; i < 18; i++) storage.setItem(i, new ItemStack(Items.IRON_INGOT, 64));
+ helper.onInventoryChanged();
+
+ int[] beforeCounts = new int[18];
+ for (int i = 0; i < 18; i++) beforeCounts[i] = storage.getItem(i).getCount();
+
+ helper.onCraftedIntoStorage();
+
+ for (int i = 0; i < 18; i++) {
+ assertEquals(beforeCounts[i], storage.getItem(i).getCount(),
+ "storage slot " + i + " was unexpectedly modified");
+ }
+ }
+
+ //endregion
+}
diff --git a/expansion/src/test/java/mrtjp/projectred/expansion/package-info.java b/expansion/src/test/java/mrtjp/projectred/expansion/package-info.java
new file mode 100644
index 000000000..4a53b0367
--- /dev/null
+++ b/expansion/src/test/java/mrtjp/projectred/expansion/package-info.java
@@ -0,0 +1,9 @@
+@FieldsAreNonnullByDefault
+@MethodsReturnNonnullByDefault
+@ParametersAreNonnullByDefault
+package mrtjp.projectred.expansion;
+
+import net.covers1624.quack.annotation.FieldsAreNonnullByDefault;
+import net.covers1624.quack.annotation.MethodsReturnNonnullByDefault;
+
+import javax.annotation.ParametersAreNonnullByDefault;