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 foods; + private List tracks; + private ServerMock server; + private Inventory hopper; + private Inventory chest; + private long now; + private final HopperFoodStacker stacker = new HopperFoodStacker(); + + @BeforeEach + void setUp() { + server = MockBukkit.mock(); + previous = Cooking.plugin; + Cooking.plugin = mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); + when(Cooking.plugin.namespace()).thenReturn("cooking"); + foods = new ArrayList<>(FoodLoader.oList); + FoodLoader.oList.clear(); + tracks = new ArrayList<>(TrackLoader.oList); + TrackLoader.oList.clear(); + TrackLoader.oList.add(new TagTrack("freshness", true, List.of( + new TagStep("fresh", "Fresh", 0, 1, 1), new TagStep("stale", "Stale", 1_000_000, 1, 1)))); + MemoryConfiguration config = new MemoryConfiguration(); + config.set("name", "egg"); + FoodItem egg = new FoodItem("egg", config); + egg.addOrModifyTrack(TrackLoader.getByString("freshness")); + FoodLoader.oList.add(egg); + hopper = server.createInventory(null, InventoryType.HOPPER); + chest = server.createInventory(null, 27); + now = System.currentTimeMillis(); + } + + @AfterEach + void tearDown() { + FoodLoader.oList.clear(); + FoodLoader.oList.addAll(foods); + TrackLoader.oList.clear(); + TrackLoader.oList.addAll(tracks); + MockBukkit.unmock(); + Cooking.plugin = previous; + } + + /** An egg whose stored freshness is value as of secondsAgo seconds ago, at the given stars. */ + private ItemStack egg(int amount, int value, long secondsAgo, int quality) { + FoodItem food = new FoodItem(FoodLoader.getByString("egg")); + food.getTagTrack("freshness").setValue(value); + food.setLastUpdate(now - secondsAgo * 1000L); + food.setQualityRange(quality, quality); + return ItemBuilder.stamp(new ItemStack(Material.EGG, amount), food, "egg"); + } + + private ItemStack egg(int amount, long secondsAgo) { + return egg(amount, 0, secondsAgo, 1); + } + + private static long clock(ItemStack item) { + return item.getItemMeta().getPersistentDataContainer().get(Keys.LAST_UPDATE, PersistentDataType.LONG); + } + + /** A hopper pushing moved into chest, after Cooking's listeners ran and nothing cancelled it. */ + private InventoryMoveItemEvent push(ItemStack moved) { + InventoryMoveItemEvent event = new InventoryMoveItemEvent(hopper, moved, chest, true); + stacker.onMove(event); + stacker.afterMove(event); + return event; + } + + /** What vanilla does with an item that matches slot 0 exactly. */ + private static void addTo(Inventory inventory, int slot, ItemStack item) { + ItemStack current = inventory.getItem(slot); + assertTrue(current.isSimilar(item)); + current.setAmount(current.getAmount() + item.getAmount()); + inventory.setItem(slot, current); + } + + @Test + void aFresherEggTakesTheStoredStacksDataSoItStacks() { + ItemStack stored = egg(3, 600); + chest.setItem(0, stored); + + InventoryMoveItemEvent event = push(egg(1, 0)); + + assertTrue(event.getItem().isSimilar(stored)); + assertEquals(1, event.getItem().getAmount()); + assertTrue(chest.getItem(0).isSimilar(stored)); + assertEquals(3, chest.getItem(0).getAmount()); + } + + @Test + void anOlderEggGivesTheStackItsDataOnceNothingCancelledTheMove() { + ItemStack stored = egg(3, 0); + chest.setItem(0, stored); + ItemStack moved = egg(1, 600); + InventoryMoveItemEvent event = new InventoryMoveItemEvent(hopper, moved, chest, true); + + stacker.onMove(event); + // Nothing in the chest changes while the move can still be cancelled. + assertTrue(chest.getItem(0).isSimilar(stored)); + stacker.afterMove(event); + addTo(chest, 0, event.getItem()); + + assertTrue(event.getItem().isSimilar(moved)); + assertTrue(chest.getItem(0).isSimilar(moved)); + assertEquals(4, chest.getItem(0).getAmount()); + } + + @Test + void aCancelledMoveChangesNothingOutsideTheEvent() { + ItemStack stored = egg(3, 0); + chest.setItem(0, stored); + ItemStack moved = egg(1, 600); + InventoryMoveItemEvent event = new InventoryMoveItemEvent(hopper, moved, chest, true); + + // MONITOR (afterMove) never runs for a cancelled event. + stacker.onMove(event); + + assertTrue(chest.getItem(0).isSimilar(stored)); + assertEquals(3, chest.getItem(0).getAmount()); + assertTrue(event.getItem().isSimilar(moved)); + } + + @Test + void equallyAgedEggsKeepTheStoredStacksData() { + // Ten points stored now and none stored ten seconds ago are the same age. + chest.setItem(0, egg(2, 0, 10, 1)); + ItemStack stored = chest.getItem(0).clone(); + + InventoryMoveItemEvent event = push(egg(1, 10, 0, 1)); + + assertTrue(event.getItem().isSimilar(stored)); + assertTrue(chest.getItem(0).isSimilar(stored)); + } + + @Test + void stackChangesWaitUntilNothingCanCancelTheEvent() throws Exception { + var move = HopperFoodStacker.class.getMethod("onMove", InventoryMoveItemEvent.class) + .getAnnotation(org.bukkit.event.EventHandler.class); + var after = HopperFoodStacker.class.getMethod("afterMove", InventoryMoveItemEvent.class) + .getAnnotation(org.bukkit.event.EventHandler.class); + var pickup = HopperFoodStacker.class.getMethod("onPickup", InventoryPickupItemEvent.class) + .getAnnotation(org.bukkit.event.EventHandler.class); + assertEquals(org.bukkit.event.EventPriority.HIGHEST, move.priority()); + assertEquals(org.bukkit.event.EventPriority.MONITOR, after.priority()); + assertEquals(org.bukkit.event.EventPriority.MONITOR, pickup.priority()); + assertTrue(move.ignoreCancelled() && after.ignoreCancelled() && pickup.ignoreCancelled()); + } + + @Test + void anEarlierEmptySlotIsLeftToVanilla() { + chest.setItem(1, egg(3, 600)); + ItemStack moved = egg(1, 0); + + assertTrue(push(moved).getItem().isSimilar(moved)); + } + + @Test + void anExactCopyWithRoomIsLeftToVanilla() { + ItemStack moved = egg(1, 0); + ItemStack copy = moved.clone(); + copy.setAmount(4); + chest.setItem(0, copy); + chest.setItem(1, egg(3, 600)); + + assertTrue(push(moved).getItem().isSimilar(moved)); + } + + @Test + void fullStacksOtherItemsAndOtherStarsAreSkipped() { + chest.setItem(0, egg(16, 600)); + chest.setItem(1, new ItemStack(Material.EGG, 2)); + chest.setItem(2, new ItemStack(Material.STONE)); + chest.setItem(3, egg(2, 0, 600, 3)); + chest.setItem(4, egg(2, 300)); + + InventoryMoveItemEvent event = push(egg(1, 0)); + + assertTrue(event.getItem().isSimilar(chest.getItem(4))); + assertEquals(16, chest.getItem(0).getAmount()); + } + + @Test + void foodThatDiffersBeyondAgingIsSkipped() { + ItemStack named = egg(2, 600); + ItemMeta meta = named.getItemMeta(); + meta.setDisplayName("Golden egg"); + named.setItemMeta(meta); + chest.setItem(0, named); + ItemStack moved = egg(1, 0); + + assertTrue(push(moved).getItem().isSimilar(moved)); + } + + @Test + void aFullChestChangesNothing() { + for (int slot = 0; slot < chest.getSize(); slot++) chest.setItem(slot, egg(16, 600)); + ItemStack moved = egg(1, 0); + + assertTrue(push(moved).getItem().isSimilar(moved)); + } + + @Test + void itemsThatAreNotFoodAreLeftAlone() { + chest.setItem(0, new ItemStack(Material.EGG, 2)); + ItemStack named = new ItemStack(Material.EGG); + ItemMeta meta = named.getItemMeta(); + meta.setDisplayName("Painted egg"); + named.setItemMeta(meta); + + assertTrue(push(named).getItem().isSimilar(named)); + assertTrue(push(new ItemStack(Material.EGG)).getItem().isSimilar(new ItemStack(Material.EGG))); + } + + @Test + void aHopperPullingIntoItselfStacksToo() { + Inventory above = server.createInventory(null, 27); + hopper.setItem(0, egg(2, 600)); + InventoryMoveItemEvent event = new InventoryMoveItemEvent(above, egg(1, 0), hopper, false); + + stacker.onMove(event); + stacker.afterMove(event); + + assertTrue(event.getItem().isSimilar(hopper.getItem(0))); + } + + @Test + void droppersAreLeftAlone() { + Inventory dropper = server.createInventory(null, InventoryType.DROPPER); + chest.setItem(0, egg(3, 600)); + ItemStack moved = egg(1, 0); + InventoryMoveItemEvent event = new InventoryMoveItemEvent(dropper, moved, chest, true); + + stacker.onMove(event); + stacker.afterMove(event); + + assertTrue(event.getItem().isSimilar(moved)); + } + + @Test + void containersWithSlotRulesAreLeftAlone() { + Inventory furnace = server.createInventory(null, InventoryType.FURNACE); + furnace.setItem(0, egg(3, 600)); + ItemStack moved = egg(1, 0); + InventoryMoveItemEvent event = new InventoryMoveItemEvent(hopper, moved, furnace, true); + + stacker.onMove(event); + stacker.afterMove(event); + + assertTrue(event.getItem().isSimilar(moved)); + } + + @Test + void aHopperPickingUpAFresherEggGivesTheGroundItemTheStoredData() { + WorldMock world = server.addSimpleWorld("world"); + ItemStack stored = egg(2, 600); + hopper.setItem(0, stored); + Item ground = world.dropItem(world.getSpawnLocation(), egg(3, 0)); + + stacker.onPickup(new InventoryPickupItemEvent(hopper, ground)); + + assertTrue(ground.getItemStack().isSimilar(stored)); + assertEquals(3, ground.getItemStack().getAmount()); + assertTrue(hopper.getItem(0).isSimilar(stored)); + assertEquals(2, hopper.getItem(0).getAmount()); + } + + @Test + void aHopperPickingUpAnOlderEggGivesTheStackItsData() { + WorldMock world = server.addSimpleWorld("world"); + hopper.setItem(0, egg(2, 0)); + ItemStack old = egg(1, 600); + Item ground = world.dropItem(world.getSpawnLocation(), old); + + stacker.onPickup(new InventoryPickupItemEvent(hopper, ground)); + + assertTrue(ground.getItemStack().isSimilar(old)); + assertTrue(hopper.getItem(0).isSimilar(old)); + assertEquals(2, hopper.getItem(0).getAmount()); + } + + @Test + void pickupsIntoOtherInventoriesAreLeftAlone() { + WorldMock world = server.addSimpleWorld("world"); + Inventory furnace = server.createInventory(null, InventoryType.FURNACE); + furnace.setItem(0, egg(2, 600)); + ItemStack fresh = egg(1, 0); + Item ground = world.dropItem(world.getSpawnLocation(), fresh); + + stacker.onPickup(new InventoryPickupItemEvent(furnace, ground)); + + assertTrue(ground.getItemStack().isSimilar(fresh)); + } + + @Test + void foodThatWouldNotAllFitIsLeftToVanilla() { + hopper.setItem(0, egg(14, 0)); + assertNull(HopperFoodStacker.merge(hopper, egg(3, 600))); + assertNotNull(HopperFoodStacker.merge(hopper, egg(2, 600))); + } + + @Test + void aLaterExactCopyWithRoomIsLeftToVanilla() { + ItemStack moved = egg(1, 0); + ItemStack aged = egg(2, 600); + chest.setItem(0, aged); + ItemStack copy = moved.clone(); + copy.setAmount(3); + chest.setItem(1, copy); + + assertTrue(push(moved).getItem().isSimilar(moved)); + assertTrue(chest.getItem(0).isSimilar(aged)); + + copy.setAmount(16); + chest.setItem(1, copy); + assertTrue(push(moved).getItem().isSimilar(aged)); + } + + @Test + void onlyFoodThatNeedsNewDataGetsAMerge() { + assertNull(HopperFoodStacker.merge(chest, null)); + assertNull(HopperFoodStacker.merge(chest, new ItemStack(Material.AIR))); + ItemStack moved = egg(1, 0); + assertNull(HopperFoodStacker.merge(chest, moved)); + chest.setItem(0, egg(2, 600)); + HopperFoodStacker.Merge merge = HopperFoodStacker.merge(chest, moved); + assertNotNull(merge); + assertFalse(merge.incomingIsOlder()); + assertEquals(0, merge.slot()); + } +}