diff --git a/pom.xml b/pom.xml index ff72b79..3c67c1c 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,32 @@ 2.1.0 + + + + + org.junit + junit-bom + 5.10.2 + pom + import + + + + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.95.0 + test + + + org.mockito + mockito-core + 5.20.0 + test + local gson diff --git a/src/main/java/net/tfminecraft/cooking/cooking/FryingReference.java b/src/main/java/net/tfminecraft/cooking/cooking/FryingReference.java index 1816206..0d3e5d1 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/FryingReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/FryingReference.java @@ -106,7 +106,8 @@ public void interact(FurnitureInteractEvent e) { ItemStack converted = IngredientConverter.convertIfNeeded(p, item); if (converted != item) { p.getInventory().setItemInMainHand(converted); - item = converted; + // setItemInMainHand stores a copy, so take from the hand itself or nothing is used up. + item = p.getInventory().getItemInMainHand(); } FoodItem butterFi = FoodItem.fromItem(item); diff --git a/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java b/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java index 68bfbdf..742247a 100644 --- a/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java +++ b/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java @@ -1,5 +1,7 @@ package net.tfminecraft.cooking.cup; +import java.util.function.Supplier; + import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Material; @@ -66,24 +68,35 @@ public void onDrink(PlayerItemConsumeEvent event) { } int amountBefore = consumed.getAmount(); - Bukkit.getScheduler().runTask(Cooking.plugin, () -> replaceWithEmptyCup(player, amountBefore)); + EquipmentSlot hand = event.getHand() == EquipmentSlot.OFF_HAND ? EquipmentSlot.OFF_HAND : EquipmentSlot.HAND; + int slot = hand == EquipmentSlot.OFF_HAND ? -1 : player.getInventory().getHeldItemSlot(); + Bukkit.getScheduler().runTask(Cooking.plugin, () -> replaceWithEmptyCup(player, amountBefore, slot)); + } + + /** + * Swaps the glass bottle vanilla leaves in the slot the cup was drunk from for an empty cup. + * Only that slot, and only if it still holds the bottle: switching slots within the tick + * must neither leave the bottle and add a cup, nor overwrite whatever is held instead. Cups + * are potions, so an empty slot means the bottle was moved away, or nothing was used up. + */ + static void replaceWithEmptyCup(Player player, int amountBefore, int slot) { + replaceWithEmptyCup(player, amountBefore, slot, CupItems::emptyCup); } - private static void replaceWithEmptyCup(Player player, int amountBefore) { + static void replaceWithEmptyCup(Player player, int amountBefore, int slot, Supplier emptyCup) { if (amountBefore > 1) { return; } - - ItemStack empty = CupItems.emptyCup(); - ItemStack main = player.getInventory().getItemInMainHand(); - ItemStack off = player.getInventory().getItemInOffHand(); - - if (main.getType() == Material.GLASS_BOTTLE || main.getType() == Material.AIR) { - player.getInventory().setItemInMainHand(empty); - } else if (off.getType() == Material.GLASS_BOTTLE) { - player.getInventory().setItemInOffHand(empty); - } else if (!ItemCache.isCupOfWater(main) && !ItemCache.isCupOfMilk(main)) { - player.getInventory().setItemInMainHand(empty); + PlayerInventory inv = player.getInventory(); + ItemStack left = slot < 0 ? inv.getItemInOffHand() : inv.getItem(slot); + if (left == null || left.getType() != Material.GLASS_BOTTLE) { + return; + } + ItemStack empty = emptyCup.get(); + if (slot < 0) { + inv.setItemInOffHand(empty); + } else { + inv.setItem(slot, empty); } } diff --git a/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java b/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java index aa846f5..f44688b 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java @@ -23,6 +23,7 @@ import net.tfminecraft.cooking.utils.FoodParser; import net.tfminecraft.cooking.utils.InventoryAdder; import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.Keys; public class ConversionManager implements Listener { @@ -132,7 +133,7 @@ public void onOpen(InventoryOpenEvent e) { FoodItem fb = FoodItem.fromItem(b); if (fb == null) continue; - if (InventoryAdder.equalsFood(fa, fb)) { + if (InventoryAdder.equalsFood(fa, fb) && sameApartFromAging(a, b)) { ItemStack clone = b.clone(); clone.setAmount(a.getAmount()); setSlot(i, clone, top, bottom, topSize); @@ -142,6 +143,32 @@ public void onOpen(InventoryOpenEvent e) { } } + /** + * True when two food stacks differ only in how far they have aged, so one can be copied over + * the other to let them stack. equalsFood matches by kind and tag step alone, so without this + * a carved roast, a sausage chain or a big catch would be replaced by a fresh copy of another. + */ + public static boolean sameApartFromAging(ItemStack a, ItemStack b) { + return withoutAging(a).isSimilar(withoutAging(b)); + } + + private static ItemStack withoutAging(ItemStack item) { + ItemStack copy = item.clone(); + copy.setAmount(1); + ItemMeta meta = copy.getItemMeta(); + if (meta == null) return copy; + var pdc = meta.getPersistentDataContainer(); + // The clock, the progress within each tag step, and the lore written from them. Food lore + // is always rebuilt whole from the item's data (ItemBuilder.stamp), which is compared. + pdc.remove(Keys.LAST_UPDATE); + pdc.remove(Keys.AGE_REMAINDER); + pdc.remove(Keys.TAGS); + pdc.remove(Keys.LORE_INDEX_MAP); + meta.lore(null); + copy.setItemMeta(meta); + return copy; + } + private ItemStack getSlot(int index, Inventory top, Inventory bottom, int topSize) { return index < topSize ? top.getItem(index) : bottom.getItem(index - topSize); } diff --git a/src/main/java/net/tfminecraft/cooking/manager/CookingManager.java b/src/main/java/net/tfminecraft/cooking/manager/CookingManager.java index 56403e8..f5901f0 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/CookingManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/CookingManager.java @@ -13,6 +13,7 @@ import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.scheduler.BukkitRunnable; @@ -27,6 +28,7 @@ import net.tfminecraft.cooking.cooking.SauceReference; import net.tfminecraft.cooking.enums.Method; import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.utils.StationChunks; import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemAddEvent; @@ -76,6 +78,13 @@ private void resumeStationsInChunk(Chunk chunk) { } } + /** Drops references to furniture in an unloading chunk; loading it again builds fresh ones. */ + @EventHandler + public void onChunkUnload(ChunkUnloadEvent event) { + Chunk chunk = event.getChunk(); + stations.values().removeIf(ref -> StationChunks.isIn(ref.getFurniture(), chunk)); + } + @EventHandler public void onChunkLoad(ChunkLoadEvent event) { Chunk chunk = event.getChunk(); @@ -97,7 +106,9 @@ private CookingReference createReference(Furniture furniture, Method method) { private CookingReference getOrCreateReference(Furniture furniture) { CookingReference existing = stations.get(furniture.getEntityId()); - if (existing != null) { + // A chunk reload replaces the furniture object under the same UUID. A reference still + // holding the old one would hand out items the live furniture also keeps, so rebuild it. + if (existing != null && existing.getFurniture() == furniture) { return existing; } Method method = FurnitureCache.getByFurniture(furniture); diff --git a/src/main/java/net/tfminecraft/cooking/manager/CraftingManager.java b/src/main/java/net/tfminecraft/cooking/manager/CraftingManager.java index 2847d0c..2d1d965 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/CraftingManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/CraftingManager.java @@ -16,6 +16,7 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.util.Transformation; @@ -34,6 +35,7 @@ import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.item.data.CookData; import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.StationChunks; import net.tfminecraft.cooking.loader.CraftingStationLoader; import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; @@ -92,7 +94,8 @@ private boolean isCraftingStationFurniture(Furniture f) { public CraftingStation getOrCreateStation(Furniture f) { if (f.getType() == null) return null; CraftingStation existing = stations.get(f.getEntityId()); - if (existing != null) return existing; + // A chunk reload replaces the furniture object under the same UUID; rebuild from the live one. + if (existing != null && existing.getFurniture() == f) return existing; for (CraftingStation template : CraftingStationLoader.get()) { if (template.getBlockId().equalsIgnoreCase(f.getType().getId())) { @@ -105,6 +108,13 @@ public CraftingStation getOrCreateStation(Furniture f) { return null; } + /** Drops stations in an unloading chunk; loading it again builds fresh ones. */ + @EventHandler + public void onChunkUnload(ChunkUnloadEvent event) { + Chunk chunk = event.getChunk(); + stations.values().removeIf(station -> StationChunks.isIn(station.getFurniture(), chunk)); + } + @EventHandler public void onChunkLoad(ChunkLoadEvent event) { Chunk chunk = event.getChunk(); diff --git a/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java b/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java index eb75656..9c94927 100644 --- a/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java +++ b/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java @@ -319,7 +319,8 @@ private void handleMixinAdd(Furniture furniture, Player player, FurnitureInterac ItemStack converted = IngredientConverter.convertIfNeeded(player, hand); if (converted != hand) { player.getInventory().setItemInMainHand(converted); - hand = converted; + // setItemInMainHand stores a copy, so take from the hand itself or nothing is used up. + hand = player.getInventory().getItemInMainHand(); } FoodItem foodItem = FoodItem.fromItem(hand); diff --git a/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java b/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java index 6264502..a53768a 100644 --- a/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java +++ b/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java @@ -136,10 +136,18 @@ private void startCrank(Furniture furniture, Player player) { } private void finishCrank(Furniture furniture, Player player) { + if (!player.isOnline()) { + return; + } List meats = collectMeats(furniture); if (meats.size() < MEAT_SLOTS.length) { return; } + // The paper was checked when the crank started; it could have been put away since. + if (!hasPaper(player)) { + player.sendMessage("§cHold paper to casing."); + return; + } ItemStack chain = SausageItems.fromMeats(player, meats); if (chain == null) { diff --git a/src/main/java/net/tfminecraft/cooking/utils/StationChunks.java b/src/main/java/net/tfminecraft/cooking/utils/StationChunks.java new file mode 100644 index 0000000..55cf733 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/utils/StationChunks.java @@ -0,0 +1,27 @@ +package net.tfminecraft.cooking.utils; + +import org.bukkit.Chunk; +import org.bukkit.Location; + +import net.tfminecraft.interactiblefurniture.furniture.Furniture; + +/** Chunk checks for cached stations that never load a chunk to answer. */ +public final class StationChunks { + + private StationChunks() { + } + + /** True when the furniture sits in this chunk. A carried piece counts where it is now. */ + public static boolean isIn(Furniture furniture, Chunk chunk) { + if (furniture == null || chunk == null) return false; + Location loc = furniture.getLoc(); + return isIn(loc, chunk); + } + + static boolean isIn(Location loc, Chunk chunk) { + if (loc == null || loc.getWorld() == null || chunk.getWorld() == null) return false; + return loc.getWorld().getUID().equals(chunk.getWorld().getUID()) + && (loc.getBlockX() >> 4) == chunk.getX() + && (loc.getBlockZ() >> 4) == chunk.getZ(); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/cup/EmptyCupReturnTest.java b/src/test/java/net/tfminecraft/cooking/cup/EmptyCupReturnTest.java new file mode 100644 index 0000000..8e668ba --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/cup/EmptyCupReturnTest.java @@ -0,0 +1,80 @@ +package net.tfminecraft.cooking.cup; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +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.entity.PlayerMock; + +/** A drunk cup leaves a glass bottle in its slot, which becomes an empty cup there and nowhere else. */ +class EmptyCupReturnTest { + private PlayerMock player; + private PlayerInventory inventory; + + @BeforeEach + void setUp() { + player = MockBukkit.mock().addPlayer(); + inventory = player.getInventory(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + private static ItemStack cup() { + return new ItemStack(Material.BOWL); + } + + @Test + void theBottleInTheSlotDrunkFromBecomesACup() { + inventory.setItem(3, new ItemStack(Material.GLASS_BOTTLE)); + + DrinkConsumeListener.replaceWithEmptyCup(player, 1, 3, EmptyCupReturnTest::cup); + + assertEquals(Material.BOWL, inventory.getItem(3).getType()); + } + + @Test + void switchingSlotsDoesNotOverwriteTheNewItem() { + inventory.setItem(3, new ItemStack(Material.GLASS_BOTTLE)); + inventory.setItem(5, new ItemStack(Material.DIAMOND_SWORD)); + inventory.setHeldItemSlot(5); + + DrinkConsumeListener.replaceWithEmptyCup(player, 1, 3, EmptyCupReturnTest::cup); + + assertEquals(Material.BOWL, inventory.getItem(3).getType()); + assertEquals(Material.DIAMOND_SWORD, inventory.getItem(5).getType()); + } + + @Test + void aBottleMovedAwayGetsNoCupAsWell() { + inventory.setItem(3, new ItemStack(Material.BREAD)); + + DrinkConsumeListener.replaceWithEmptyCup(player, 1, 3, () -> fail("no cup should be made")); + + assertEquals(Material.BREAD, inventory.getItem(3).getType()); + } + + @Test + void aSlotEmptiedOfItsBottleGetsNoCup() { + DrinkConsumeListener.replaceWithEmptyCup(player, 1, 3, () -> fail("no cup should be made")); + + assertEquals(Material.AIR, inventory.getItem(3) == null ? Material.AIR : inventory.getItem(3).getType()); + } + + @Test + void theOffHandIsUsedWhenTheCupWasDrunkFromIt() { + inventory.setItemInOffHand(new ItemStack(Material.GLASS_BOTTLE)); + + DrinkConsumeListener.replaceWithEmptyCup(player, 1, -1, EmptyCupReturnTest::cup); + + assertEquals(Material.BOWL, inventory.getItemInOffHand().getType()); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/manager/FoodStackMergeTest.java b/src/test/java/net/tfminecraft/cooking/manager/FoodStackMergeTest.java new file mode 100644 index 0000000..d2aae9b --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/manager/FoodStackMergeTest.java @@ -0,0 +1,95 @@ +package net.tfminecraft.cooking.manager; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +import net.kyori.adventure.text.Component; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.utils.Keys; + +/** + * Opening a chest copies one food over another equal one so the two stack. Only the aging may + * differ; anything else, such as how far a roast has been carved, must stay with its own item. + */ +class FoodStackMergeTest { + private static Cooking previous; + + @BeforeAll + static void setUp() { + MockBukkit.mock(); + previous = Cooking.plugin; + Cooking plugin = mock(Cooking.class); + when(plugin.namespace()).thenReturn("cooking"); + when(plugin.getName()).thenReturn("Cooking"); + Cooking.plugin = plugin; + } + + @AfterAll + static void tearDown() { + Cooking.plugin = previous; + MockBukkit.unmock(); + } + + private static ItemStack roast(int amount, long lastUpdate, String tags, String lore) { + ItemStack item = new ItemStack(Material.COOKED_BEEF, amount); + ItemMeta meta = item.getItemMeta(); + var pdc = meta.getPersistentDataContainer(); + pdc.set(Keys.FOOD_ID, PersistentDataType.STRING, "beef_roast"); + pdc.set(Keys.LAST_UPDATE, PersistentDataType.LONG, lastUpdate); + pdc.set(Keys.TAGS, PersistentDataType.STRING, tags); + meta.lore(List.of(Component.text(lore))); + item.setItemMeta(meta); + return item; + } + + private static ItemStack with(ItemStack item, java.util.function.Consumer change) { + ItemStack copy = item.clone(); + ItemMeta meta = copy.getItemMeta(); + change.accept(meta); + copy.setItemMeta(meta); + return copy; + } + + @Test + void foodsThatOnlyAgedDifferentlyCanMerge() { + ItemStack older = roast(3, 1_000L, "cooked.3", "Fresh"); + ItemStack newer = roast(1, 9_000L, "cooked.7", "Fresh, 2 minutes"); + + assertTrue(ConversionManager.sameApartFromAging(older, newer)); + } + + @Test + void aCarvedRoastIsNotReplacedByAWholeOne() { + ItemStack whole = roast(1, 1_000L, "cooked.3", "Fresh"); + ItemStack carved = with(whole, m -> m.getPersistentDataContainer() + .set(Keys.CARVE_REMAINING, PersistentDataType.INTEGER, 2)); + ItemStack full = with(whole, m -> m.getPersistentDataContainer() + .set(Keys.CARVE_REMAINING, PersistentDataType.INTEGER, 8)); + + assertFalse(ConversionManager.sameApartFromAging(carved, full)); + } + + @Test + void anyOtherDifferenceKeepsTheItemsApart() { + ItemStack plain = roast(1, 1_000L, "cooked.3", "Fresh"); + + assertFalse(ConversionManager.sameApartFromAging(plain, with(plain, m -> m.getPersistentDataContainer() + .set(Keys.BASE_FOOD, PersistentDataType.DOUBLE, 12.0)))); + assertFalse(ConversionManager.sameApartFromAging(plain, with(plain, m -> m.getPersistentDataContainer() + .set(Keys.CATCH_SIZE_CM, PersistentDataType.INTEGER, 90)))); + assertFalse(ConversionManager.sameApartFromAging(plain, new ItemStack(Material.COOKED_PORKCHOP))); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/utils/StationChunksTest.java b/src/test/java/net/tfminecraft/cooking/utils/StationChunksTest.java new file mode 100644 index 0000000..ac3ecbc --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/utils/StationChunksTest.java @@ -0,0 +1,41 @@ +package net.tfminecraft.cooking.utils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +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; + +class StationChunksTest { + private ServerMock server; + + @BeforeEach + void setUp() { + server = MockBukkit.mock(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void findsFurnitureInTheUnloadingChunkOnly() { + World world = server.addSimpleWorld("world"); + World other = server.addSimpleWorld("other"); + Chunk chunk = world.getChunkAt(1, -2); + + assertTrue(StationChunks.isIn(new Location(world, 16, 64, -32), chunk)); + assertTrue(StationChunks.isIn(new Location(world, 31.9, 64, -17.5), chunk)); + assertFalse(StationChunks.isIn(new Location(world, 32, 64, -32), chunk)); + assertFalse(StationChunks.isIn(new Location(world, 16, 64, -33), chunk)); + assertFalse(StationChunks.isIn(new Location(other, 16, 64, -32), chunk)); + assertFalse(StationChunks.isIn((Location) null, chunk)); + } +}