diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index d1cb93c..7dc7fa4 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -57,6 +57,7 @@ import net.tfminecraft.cooking.manager.ConversionManager; import net.tfminecraft.cooking.manager.CookingManager; import net.tfminecraft.cooking.manager.CraftingManager; +import net.tfminecraft.cooking.manager.HopperFoodStacker; import net.tfminecraft.cooking.manager.PlateManager; import net.tfminecraft.cooking.manager.TagManager; import net.tfminecraft.cooking.baking.BakingTrayAging; @@ -394,6 +395,7 @@ public void registerListeners() { } getServer().getPluginManager().registerEvents(new ConversionManager(), this); getServer().getPluginManager().registerEvents(new BundleFoodStacker(), this); + getServer().getPluginManager().registerEvents(new HopperFoodStacker(), this); getServer().getPluginManager().registerEvents(new PopcornMachineHandler(), this); getServer().getPluginManager().registerEvents(new GlazeHandler(), this); getServer().getPluginManager().registerEvents(new RegenBlocker(), this); diff --git a/src/main/java/net/tfminecraft/cooking/manager/HopperFoodStacker.java b/src/main/java/net/tfminecraft/cooking/manager/HopperFoodStacker.java new file mode 100644 index 0000000..58c9629 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/manager/HopperFoodStacker.java @@ -0,0 +1,148 @@ +package net.tfminecraft.cooking.manager; + +import org.bukkit.entity.Item; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryMoveItemEvent; +import org.bukkit.event.inventory.InventoryPickupItemEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.Keys; + +/** + * Aging foods carry their own clock, so hoppers, which only stack exact copies, give every piece + * of the same food its own slot: eggs laid an hour apart fill a hopper five slots at a time. When a + * hopper moves food into a storage block, or picks it up off the ground, and the slot vanilla would + * fill first holds an equal food that only aged differently, the fresher side takes the older one's + * data so vanilla stacks them there. Food never gets fresher, and vanilla still does the moving. + * + *
Other plugins may cancel these events, so nothing outside the event changes before they are
+ * done. A fresher moved item takes the stored data through the event at HIGHEST, which a cancelled
+ * move throws away. Changes to the stored stack or to a ground item wait for MONITOR, which only
+ * runs for an event nothing cancelled; vanilla adds the item straight after it.
+ */
+public class HopperFoodStacker implements Listener {
+
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onMove(InventoryMoveItemEvent event) {
+ if (!hopperIntoStorage(event)) return;
+ Merge merge = merge(event.getDestination(), event.getItem());
+ if (merge != null && !merge.incomingIsOlder()) event.setItem(merge.carried());
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void afterMove(InventoryMoveItemEvent event) {
+ if (!hopperIntoStorage(event)) return;
+ // A fresher item already carries the stored data, so only an older one still merges here.
+ Merge merge = merge(event.getDestination(), event.getItem());
+ if (merge != null && merge.incomingIsOlder()) merge.ageStoredStack();
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onPickup(InventoryPickupItemEvent event) {
+ Inventory destination = event.getInventory();
+ if (!storage(destination)) return;
+ // The hopper copies the ground item after this event, and all of it fits in the slot.
+ Item entity = event.getItem();
+ Merge merge = merge(destination, entity.getItemStack());
+ if (merge == null) return;
+ if (merge.incomingIsOlder()) {
+ merge.ageStoredStack();
+ } else {
+ entity.setItemStack(merge.carried());
+ }
+ }
+
+ /**
+ * Hoppers take the item out of the source before the event and add whatever it carries after,
+ * so a changed copy moves cleanly. A dropper keeps the source item when it changes.
+ */
+ private static boolean hopperIntoStorage(InventoryMoveItemEvent event) {
+ return event.getInitiator().getType() == InventoryType.HOPPER && storage(event.getDestination());
+ }
+
+ /** Containers that take an item in any slot from any side, in slot order. */
+ private static boolean storage(Inventory inventory) {
+ return switch (inventory.getType()) {
+ case CHEST, BARREL, HOPPER, SHULKER_BOX, DISPENSER, DROPPER -> true;
+ default -> false;
+ };
+ }
+
+ /**
+ * The incoming food meets an equal food that aged differently in the first slot vanilla would
+ * fill, with room for all of it.
+ */
+ record Merge(Inventory destination, int slot, ItemStack stored, ItemStack incoming, boolean incomingIsOlder) {
+
+ /** The incoming food carrying the stored stack's data, so vanilla stacks it there. */
+ ItemStack carried() {
+ ItemStack out = stored.clone();
+ out.setAmount(incoming.getAmount());
+ return out;
+ }
+
+ /** The stored stack carrying the older incoming food's data, so vanilla stacks it there. */
+ void ageStoredStack() {
+ ItemStack older = incoming.clone();
+ older.setAmount(stored.getAmount());
+ destination.setItem(slot, older);
+ }
+ }
+
+ /**
+ * Where the incoming food can stack after all, or null when vanilla handles it as it is. Vanilla
+ * fills the first slot that is empty or holds an exact copy with room; a merge is offered when
+ * that slot instead holds an equal food that aged differently and has room for all of it, and no
+ * later slot holds an exact copy with room.
+ */
+ static Merge merge(Inventory destination, ItemStack incoming) {
+ // Most hopper traffic is not food; read the food id without copying the item's meta.
+ if (incoming == null || incoming.isEmpty() || !incoming.hasItemMeta()
+ || !incoming.getPersistentDataContainer().has(Keys.FOOD_ID, PersistentDataType.STRING)) {
+ return null;
+ }
+ FoodItem food = null;
+ for (int slot = 0; slot < destination.getSize(); slot++) {
+ ItemStack current = destination.getItem(slot);
+ if (current == null || current.isEmpty()) return null;
+ if (current.getType() != incoming.getType()) continue;
+ int room = room(destination, current);
+ if (room <= 0) continue;
+ if (current.isSimilar(incoming)) return null;
+
+ if (food == null) {
+ food = FoodItem.fromItem(incoming);
+ if (food == null) return null;
+ }
+ FoodItem other = FoodItem.fromItem(current);
+ if (other == null || !InventoryAdder.equalsFood(food, other)
+ || !ConversionManager.sameApartFromAging(incoming, current)) continue;
+ // Part of it would spill into later slots with the stored data; leave that to vanilla.
+ if (incoming.getAmount() > room || laterExactCopy(destination, slot, incoming)) return null;
+ // Equal age keeps the stored stack's data.
+ boolean older = BundleFoodStacker.moreAged(current, incoming) != current;
+ return new Merge(destination, slot, current.clone(), incoming.clone(), older);
+ }
+ return null;
+ }
+
+ private static int room(Inventory inventory, ItemStack stack) {
+ return Math.min(stack.getMaxStackSize(), inventory.getMaxStackSize()) - stack.getAmount();
+ }
+
+ /** True when a slot after this one already stacks the item exactly; vanilla puts it there. */
+ private static boolean laterExactCopy(Inventory destination, int after, ItemStack incoming) {
+ for (int slot = after + 1; slot < destination.getSize(); slot++) {
+ ItemStack later = destination.getItem(slot);
+ if (later != null && later.isSimilar(incoming) && room(destination, later) > 0) return true;
+ }
+ return false;
+ }
+}
diff --git a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java
index 2a48aa7..dd75c32 100644
--- a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java
+++ b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java
@@ -165,6 +165,12 @@ class CookingLifecycleTest {
assertTrue(plugin.getCommand("cooking").testPermissionSilent(caller));
assertTrue(plugin.getCommand("animals").getAliases().contains("livestock"));
assertFalse(HandlerList.getRegisteredListeners(plugin).isEmpty());
+ // Hoppers only stack aging food through this listener; nothing else would notice it missing.
+ for (HandlerList handlers : List.of(org.bukkit.event.inventory.InventoryMoveItemEvent.getHandlerList(),
+ org.bukkit.event.inventory.InventoryPickupItemEvent.getHandlerList())) {
+ assertTrue(java.util.Arrays.stream(handlers.getRegisteredListeners()).anyMatch(registered ->
+ registered.getPlugin() == plugin && registered.getListener() instanceof HopperFoodStacker));
+ }
verify(built(CookingManager.class)).start();
verify(built(PlateManager.class), never()).start();
}
diff --git a/src/test/java/net/tfminecraft/cooking/manager/HopperFoodStackerTest.java b/src/test/java/net/tfminecraft/cooking/manager/HopperFoodStackerTest.java
new file mode 100644
index 0000000..7ba5ae0
--- /dev/null
+++ b/src/test/java/net/tfminecraft/cooking/manager/HopperFoodStackerTest.java
@@ -0,0 +1,370 @@
+package net.tfminecraft.cooking.manager;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.bukkit.Material;
+import org.bukkit.configuration.MemoryConfiguration;
+import org.bukkit.entity.Item;
+import org.bukkit.event.inventory.InventoryMoveItemEvent;
+import org.bukkit.event.inventory.InventoryPickupItemEvent;
+import org.bukkit.event.inventory.InventoryType;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.bukkit.persistence.PersistentDataType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+import org.mockbukkit.mockbukkit.world.WorldMock;
+
+import net.tfminecraft.cooking.Cooking;
+import net.tfminecraft.cooking.item.FoodItem;
+import net.tfminecraft.cooking.item.tag.TagStep;
+import net.tfminecraft.cooking.item.tag.TagTrack;
+import net.tfminecraft.cooking.loader.FoodLoader;
+import net.tfminecraft.cooking.loader.TrackLoader;
+import net.tfminecraft.cooking.utils.ItemBuilder;
+import net.tfminecraft.cooking.utils.Keys;
+
+/** Hoppers stack equal foods that only aged apart, keeping the more aged copy's data. */
+class HopperFoodStackerTest {
+ private Cooking previous;
+ private List