From df8465faf8ca1b5e769563a75103a8bd12e5ef04 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:30:02 +0200 Subject: [PATCH 1/5] fix: stack equal foods in bundles Aging foods carry their own clock, so two pieces of the same meat picked up at different times never match exactly and a bundle kept them as separate entries. Inventories and chests already stack them by ignoring the aging, but nothing did that for bundle contents. After a click that involves a bundle, merge equal foods in the bundle on the cursor and in the clicked slot on the next tick, reading both again then. Foods only merge when they would stack in an inventory (same kind, origin, quality and freshness step). The merged stack keeps the more aged copy's data, so adding old food to fresh food never makes it fresher. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/cooking/Cooking.java | 2 + .../cooking/manager/BundleFoodStacker.java | 136 +++++++++++ .../manager/BundleFoodStackerTest.java | 222 ++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java create mode 100644 src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index bee9313..0c072b8 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -43,6 +43,7 @@ import net.tfminecraft.cooking.loader.PermissionEffectsLoader; import net.tfminecraft.cooking.loader.CompositionConfigLoader; import net.tfminecraft.cooking.loader.QualityConfigLoader; +import net.tfminecraft.cooking.manager.BundleFoodStacker; import net.tfminecraft.cooking.manager.CommandManager; import net.tfminecraft.cooking.manager.ConversionManager; import net.tfminecraft.cooking.manager.CookingManager; @@ -262,6 +263,7 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(new HusbandryMountListener(), this); getServer().getPluginManager().registerEvents(new HusbandryDamageListener(), this); getServer().getPluginManager().registerEvents(new ConversionManager(), this); + getServer().getPluginManager().registerEvents(new BundleFoodStacker(), this); getServer().getPluginManager().registerEvents(new RegenBlocker(), this); getServer().getPluginManager().registerEvents(new SaturationGuard(), this); getServer().getPluginManager().registerEvents(new NutritionLifecycleListener(), this); diff --git a/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java b/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java new file mode 100644 index 0000000..3046e49 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java @@ -0,0 +1,136 @@ +package net.tfminecraft.cooking.manager; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import org.bukkit.Bukkit; +import org.bukkit.Tag; +import org.bukkit.entity.HumanEntity; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BundleMeta; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.utils.InventoryAdder; + +/** + * Aging foods carry their own clock, so two pieces of the same meat never match exactly and a + * bundle keeps them apart. After a click that may have put food into a bundle, equal foods in it + * are merged the way inventories already stack them. Each merge keeps the more aged copy's data, + * so putting old food in with fresh food can never make it fresher. + */ +public class BundleFoodStacker implements Listener { + + private final Consumer nextTick; + + public BundleFoodStacker() { + this(task -> Bukkit.getScheduler().runTask(Cooking.plugin, task)); + } + + BundleFoodStacker(Consumer nextTick) { + this.nextTick = nextTick; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onClick(InventoryClickEvent e) { + if (!isBundle(e.getCursor()) && !isBundle(e.getCurrentItem())) return; + + HumanEntity player = e.getWhoClicked(); + Inventory clicked = e.getClickedInventory(); + int slot = e.getSlot(); + // The bundle changes after this event, so merge on the next tick. Read the cursor and the + // slot again then: the player may have moved things in between. + nextTick.accept(() -> stackBundles(player, clicked, slot)); + } + + static void stackBundles(HumanEntity player, Inventory clicked, int slot) { + ItemStack cursor = stack(player.getItemOnCursor()); + if (cursor != null) player.setItemOnCursor(cursor); + + if (clicked == null || !inView(player.getOpenInventory(), clicked)) return; + ItemStack inSlot = stack(clicked.getItem(slot)); + if (inSlot != null) clicked.setItem(slot, inSlot); + } + + private static boolean inView(InventoryView view, Inventory inv) { + return view.getTopInventory() == inv || view.getBottomInventory() == inv; + } + + /** The bundle with its foods merged, or null when it is not a bundle or nothing merged. */ + static ItemStack stack(ItemStack bundle) { + if (!isBundle(bundle) || !(bundle.getItemMeta() instanceof BundleMeta meta)) return null; + List merged = merge(meta.getItems()); + if (merged == null) return null; + + ItemStack out = bundle.clone(); + meta.setItems(merged); + out.setItemMeta(meta); + return out; + } + + /** Bundle contents with equal foods merged, keeping order, or null when nothing merged. */ + static List merge(List items) { + List out = new ArrayList<>(); + boolean changed = false; + + for (ItemStack item : items) { + FoodItem food = FoodItem.fromItem(item); + int amount = item.getAmount(); + + for (int i = 0; i < out.size() && food != null && amount > 0; i++) { + ItemStack target = out.get(i); + int room = target.getMaxStackSize() - target.getAmount(); + if (room <= 0 || !sameFood(food, item, target)) continue; + + int add = Math.min(room, amount); + ItemStack kept = moreAged(target, item).clone(); + kept.setAmount(target.getAmount() + add); + out.set(i, kept); + amount -= add; + changed = true; + } + + if (amount > 0) { + ItemStack rest = item.clone(); + rest.setAmount(amount); + out.add(rest); + } + } + return changed ? out : null; + } + + private static boolean sameFood(FoodItem food, ItemStack item, ItemStack other) { + if (item.getType() != other.getType()) return false; + FoodItem otherFood = FoodItem.fromItem(other); + return otherFood != null + && InventoryAdder.equalsFood(food, otherFood) + && ConversionManager.sameApartFromAging(item, other); + } + + /** Equal foods age at the same rate, so whichever is further along now is the older one. */ + static ItemStack moreAged(ItemStack a, ItemStack b) { + return age(a) >= age(b) ? a : b; + } + + private static double age(ItemStack item) { + FoodItem food = FoodItem.fromItem(item); + food.updateAge(); + double total = 0; + for (TagTrack track : food.getTagTracks()) { + if (track.isAgeable()) total += track.getValue() + food.getAgeRemainder(track.getId()); + } + return total; + } + + private static boolean isBundle(ItemStack item) { + return item != null && Tag.ITEMS_BUNDLES.isTagged(item.getType()); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java b/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java new file mode 100644 index 0000000..acab011 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java @@ -0,0 +1,222 @@ +package net.tfminecraft.cooking.manager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +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.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType.SlotType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BundleMeta; +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.entity.PlayerMock; + +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; + +/** Equal foods put into a bundle stack there as they do in an inventory, keeping the older data. */ +class BundleFoodStackerTest { + private Cooking previous; + private List foods; + private List tracks; + private ServerMock server; + private PlayerMock player; + private long now; + + @BeforeEach + void setUp() { + server = MockBukkit.mock(); + player = server.addPlayer(); + 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)))); + food("beef"); + food("pork"); + 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; + } + + private static void food(String id) { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("name", id); + FoodItem food = new FoodItem(id, config); + food.addOrModifyTrack(TrackLoader.getByString("freshness")); + FoodLoader.oList.add(food); + } + + /** A food whose stored freshness is value as of secondsAgo seconds ago. */ + private ItemStack meat(String id, Material type, int amount, int value, long secondsAgo) { + FoodItem food = new FoodItem(FoodLoader.getByString(id)); + food.getTagTrack("freshness").setValue(value); + food.setLastUpdate(now - secondsAgo * 1000L); + return ItemBuilder.stamp(new ItemStack(type, amount), food, id); + } + + private ItemStack beef(int amount, int value, long secondsAgo) { + return meat("beef", Material.BEEF, amount, value, secondsAgo); + } + + private static int freshness(ItemStack item) { + return FoodItem.fromItem(item).getTagTrack("freshness").getValue(); + } + + private static ItemStack bundle(ItemStack... items) { + ItemStack bundle = new ItemStack(Material.BUNDLE); + BundleMeta meta = (BundleMeta) bundle.getItemMeta(); + meta.setItems(List.of(items)); + bundle.setItemMeta(meta); + return bundle; + } + + private static List contents(ItemStack bundle) { + return ((BundleMeta) bundle.getItemMeta()).getItems(); + } + + @Test + void meatThatOnlyAgedDifferentlyStacksAndKeepsTheOlderData() { + // Stored freshness 50 ten seconds ago is younger than 5 stored a hundred seconds ago. + ItemStack younger = beef(2, 50, 10); + ItemStack older = beef(3, 5, 100); + + List merged = BundleFoodStacker.merge(List.of(younger, older)); + + assertEquals(1, merged.size()); + assertEquals(5, merged.get(0).getAmount()); + assertEquals(5, freshness(merged.get(0))); + } + + @Test + void otherItemsAndOtherFoodsStayApartInTheirOrder() { + ItemStack stone = new ItemStack(Material.STONE, 4); + ItemStack pork = meat("pork", Material.PORKCHOP, 1, 0, 0); + ItemStack stale = beef(1, 1_000_000, 0); + + assertNull(BundleFoodStacker.merge(List.of(stone, beef(1, 0, 0), pork, stale))); + + List merged = BundleFoodStacker.merge(List.of(stone, beef(1, 0, 0), pork, beef(2, 0, 30))); + assertEquals(List.of(Material.STONE, Material.BEEF, Material.PORKCHOP), + merged.stream().map(ItemStack::getType).toList()); + assertEquals(3, merged.get(1).getAmount()); + } + + @Test + void mergedStacksStopAtTheirMaximumAndTheRestStaysSeparate() { + List merged = BundleFoodStacker.merge(List.of(beef(60, 0, 0), beef(10, 0, 50))); + + assertEquals(2, merged.size()); + assertEquals(64, merged.get(0).getAmount()); + assertEquals(6, merged.get(1).getAmount()); + assertEquals(0, freshness(merged.get(1))); + + assertNull(BundleFoodStacker.merge(List.of(beef(64, 0, 0), beef(1, 0, 50)))); + } + + @Test + void equallyAgedFoodKeepsTheFirstCopy() { + ItemStack a = beef(1, 7, 0); + ItemStack b = beef(1, 7, 0); + + assertSame(a, BundleFoodStacker.moreAged(a, b)); + assertSame(b, BundleFoodStacker.moreAged(beef(1, 1, 0), b)); + } + + @Test + void onlyBundlesWithSomethingToMergeAreRewritten() { + assertNull(BundleFoodStacker.stack(null)); + assertNull(BundleFoodStacker.stack(beef(1, 0, 0))); + assertNull(BundleFoodStacker.stack(bundle(beef(1, 0, 0)))); + + ItemStack stacked = BundleFoodStacker.stack(bundle(beef(1, 0, 0), beef(1, 0, 20))); + assertEquals(1, contents(stacked).size()); + assertEquals(2, contents(stacked).get(0).getAmount()); + } + + @Test + void theCursorAndTheClickedSlotAreReadAgainAndRewritten() { + Inventory chest = server.createInventory(null, 27); + player.openInventory(chest); + player.setItemOnCursor(bundle(beef(1, 0, 0), beef(1, 0, 20))); + chest.setItem(4, bundle(beef(2, 0, 0), beef(2, 0, 20))); + + BundleFoodStacker.stackBundles(player, chest, 4); + + assertEquals(2, contents(player.getItemOnCursor()).get(0).getAmount()); + assertEquals(4, contents(chest.getItem(4)).get(0).getAmount()); + } + + @Test + void anInventoryNoLongerOpenOrNoSlotAtAllIsLeftAlone() { + Inventory chest = server.createInventory(null, 27); + chest.setItem(4, bundle(beef(1, 0, 0), beef(1, 0, 20))); + + BundleFoodStacker.stackBundles(player, chest, 4); + BundleFoodStacker.stackBundles(player, null, -999); + + assertEquals(2, contents(chest.getItem(4)).size()); + + player.getInventory().setItem(2, new ItemStack(Material.STONE)); + BundleFoodStacker.stackBundles(player, player.getInventory(), 2); + assertEquals(Material.STONE, player.getInventory().getItem(2).getType()); + } + + @Test + void onlyClicksInvolvingABundleScheduleAMerge() { + List tasks = new ArrayList<>(); + BundleFoodStacker stacker = new BundleFoodStacker(tasks::add); + Inventory chest = server.createInventory(null, 27); + var view = player.openInventory(chest); + + chest.setItem(0, beef(1, 0, 0)); + stacker.onClick(new InventoryClickEvent(view, SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL)); + assertEquals(0, tasks.size()); + + chest.setItem(0, bundle(beef(1, 0, 0), beef(1, 0, 20))); + stacker.onClick(new InventoryClickEvent(view, SlotType.CONTAINER, 0, ClickType.RIGHT, InventoryAction.PICKUP_HALF)); + chest.setItem(0, null); + player.setItemOnCursor(bundle(beef(1, 0, 0), beef(1, 0, 20))); + stacker.onClick(new InventoryClickEvent(view, SlotType.CONTAINER, 0, ClickType.RIGHT, InventoryAction.PICKUP_HALF)); + assertEquals(2, tasks.size()); + + chest.setItem(0, bundle(beef(1, 0, 0), beef(1, 0, 20))); + tasks.forEach(Runnable::run); + assertEquals(1, contents(chest.getItem(0)).size()); + assertEquals(1, contents(player.getItemOnCursor()).size()); + + new BundleFoodStacker(); + } +} From 1f17780d4c1c9fa2e0009ef9e40c15795af2c17c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:02:14 +0200 Subject: [PATCH 2/5] fix: keep the most aged food when a chest is opened Opening a chest copied the first equal food it found over the other so the two would stack. That could give older food a fresher copy's aging, so storing old meat next to fresh meat made it fresher. Group equal foods across the chest and the player's inventory and give them all the most aged copy's data, with ties going to the first slot. Each slot is now read and parsed once, and slots that already match are left alone. Co-Authored-By: Claude Opus 5.5 --- .../cooking/manager/ConversionManager.java | 47 +++--- .../cooking/manager/ChestOpenAgingTest.java | 136 ++++++++++++++++++ 2 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 src/test/java/net/tfminecraft/cooking/manager/ChestOpenAgingTest.java diff --git a/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java b/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java index 41bbd66..d5b8029 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java @@ -2,6 +2,8 @@ import net.tfminecraft.cooking.util.LegacyModelData; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import org.bukkit.Bukkit; @@ -172,28 +174,33 @@ public void onOpen(InventoryOpenEvent e) { int topSize = top.getSize(); int total = topSize + bottom.getSize(); - for (int i = total - 1; i >= 0; i--) { - ItemStack a = getSlot(i, top, bottom, topSize); - if (a == null) continue; - - FoodItem fa = FoodItem.fromItem(a); - if (fa == null) continue; - - for (int j = 0; j < total; j++) { - if (i == j) continue; - - ItemStack b = getSlot(j, top, bottom, topSize); - if (b == null) continue; + ItemStack[] items = new ItemStack[total]; + FoodItem[] foods = new FoodItem[total]; + for (int i = 0; i < total; i++) { + items[i] = getSlot(i, top, bottom, topSize); + foods[i] = items[i] == null ? null : FoodItem.fromItem(items[i]); + } - FoodItem fb = FoodItem.fromItem(b); - if (fb == null) continue; + // Equal foods all take the data of the most aged one, so opening a chest never makes food + // fresher. Ties go to the first slot. + for (int i = 0; i < total; i++) { + if (foods[i] == null) continue; + + List group = new ArrayList<>(List.of(i)); + ItemStack oldest = items[i]; + for (int j = i + 1; j < total; j++) { + if (foods[j] == null) continue; + if (!InventoryAdder.equalsFood(foods[i], foods[j]) || !sameApartFromAging(items[i], items[j])) continue; + group.add(j); + foods[j] = null; + oldest = BundleFoodStacker.moreAged(oldest, items[j]); + } - if (InventoryAdder.equalsFood(fa, fb) && sameApartFromAging(a, b)) { - ItemStack clone = b.clone(); - clone.setAmount(a.getAmount()); - setSlot(i, clone, top, bottom, topSize); - break; - } + for (int k : group) { + if (oldest.isSimilar(items[k])) continue; + ItemStack clone = oldest.clone(); + clone.setAmount(items[k].getAmount()); + setSlot(k, clone, top, bottom, topSize); } } } diff --git a/src/test/java/net/tfminecraft/cooking/manager/ChestOpenAgingTest.java b/src/test/java/net/tfminecraft/cooking/manager/ChestOpenAgingTest.java new file mode 100644 index 0000000..a7da53c --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/manager/ChestOpenAgingTest.java @@ -0,0 +1,136 @@ +package net.tfminecraft.cooking.manager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.event.inventory.InventoryOpenEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +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.entity.PlayerMock; + +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; + +/** Opening a chest makes equal foods stack by giving them all the most aged copy's data. */ +class ChestOpenAgingTest { + private Cooking previous; + private List foods; + private List tracks; + private ServerMock server; + private PlayerMock player; + private Inventory chest; + private long now; + + @BeforeEach + void setUp() { + server = MockBukkit.mock(); + player = server.addPlayer(); + 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", "beef"); + FoodItem beef = new FoodItem("beef", config); + beef.addOrModifyTrack(TrackLoader.getByString("freshness")); + FoodLoader.oList.add(beef); + chest = server.createInventory(null, 9); + 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; + } + + /** Beef whose stored freshness is value as of secondsAgo seconds ago. */ + private ItemStack beef(int amount, int value, long secondsAgo) { + FoodItem food = new FoodItem(FoodLoader.getByString("beef")); + food.getTagTrack("freshness").setValue(value); + food.setLastUpdate(now - secondsAgo * 1000L); + return ItemBuilder.stamp(new ItemStack(Material.BEEF, amount), food, "beef"); + } + + private void open() { + InventoryView view = mock(InventoryView.class); + when(view.getTopInventory()).thenReturn(chest); + when(view.getPlayer()).thenReturn(player); + new ConversionManager().onOpen(new InventoryOpenEvent(view)); + } + + private static long clock(ItemStack item) { + return item.getItemMeta().getPersistentDataContainer().get(Keys.LAST_UPDATE, PersistentDataType.LONG); + } + + @Test + void theFresherFoodTakesTheOlderDataWhereverEitherIs() { + chest.setItem(0, beef(2, 0, 0)); + chest.setItem(5, beef(1, 0, 100)); + player.getInventory().setItem(3, beef(4, 0, 0)); + + open(); + + long older = now - 100_000L; + assertEquals(older, clock(chest.getItem(0))); + assertEquals(older, clock(chest.getItem(5))); + assertEquals(older, clock(player.getInventory().getItem(3))); + assertEquals(2, chest.getItem(0).getAmount()); + assertEquals(1, chest.getItem(5).getAmount()); + assertEquals(4, player.getInventory().getItem(3).getAmount()); + } + + @Test + void equallyAgedFoodAllTakesTheFirstSlotsCopy() { + // Ten points stored now and none stored ten seconds ago are the same age. + chest.setItem(1, beef(1, 10, 0)); + chest.setItem(4, beef(1, 0, 10)); + + open(); + + assertEquals(now, clock(chest.getItem(1))); + assertEquals(now, clock(chest.getItem(4))); + } + + @Test + void foodAtAnotherFreshnessStepKeepsItsOwnData() { + chest.setItem(0, beef(1, 0, 0)); + chest.setItem(1, beef(1, 1_000_000, 0)); + chest.setItem(2, new ItemStack(Material.STONE)); + + open(); + + assertEquals(1_000_000, FoodItem.fromItem(chest.getItem(1)).getTagTrack("freshness").getValue()); + assertEquals(0, FoodItem.fromItem(chest.getItem(0)).getTagTrack("freshness").getValue()); + assertEquals(Material.STONE, chest.getItem(2).getType()); + } +} From 62d09fce1c2fb7f9f96a1db0d46247dabcede75c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:04:59 +0200 Subject: [PATCH 3/5] fix: let freshness decide which food is older The older copy was picked by adding up every aging track, so a food that had cooled further could win over one that had spoiled further, and a merge could then keep the fresher freshness. Compare freshness first and use the other aging tracks, such as warmth, only to break a tie. The chest-open merge uses the same rule. Co-Authored-By: Claude Opus 5.5 --- .../cooking/manager/BundleFoodStacker.java | 25 ++++++++++++++----- .../manager/BundleFoodStackerTest.java | 23 +++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java b/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java index 3046e49..089363e 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java +++ b/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java @@ -29,6 +29,8 @@ */ public class BundleFoodStacker implements Listener { + private static final String FRESHNESS = "freshness"; + private final Consumer nextTick; public BundleFoodStacker() { @@ -115,19 +117,30 @@ private static boolean sameFood(FoodItem food, ItemStack item, ItemStack other) && ConversionManager.sameApartFromAging(item, other); } - /** Equal foods age at the same rate, so whichever is further along now is the older one. */ + /** + * Equal foods age at the same rate, so whichever is further along now is the older one. + * Freshness decides, so food never gets fresher; the other aging tracks, such as warmth, only + * break a tie. + */ static ItemStack moreAged(ItemStack a, ItemStack b) { - return age(a) >= age(b) ? a : b; + double[] ageA = age(a); + double[] ageB = age(b); + int order = Double.compare(ageA[0], ageB[0]); + if (order == 0) order = Double.compare(ageA[1], ageB[1]); + return order >= 0 ? a : b; } - private static double age(ItemStack item) { + /** How far the food has aged by now: freshness first, then every other aging track. */ + private static double[] age(ItemStack item) { FoodItem food = FoodItem.fromItem(item); food.updateAge(); - double total = 0; + double[] age = new double[2]; for (TagTrack track : food.getTagTracks()) { - if (track.isAgeable()) total += track.getValue() + food.getAgeRemainder(track.getId()); + if (!track.isAgeable()) continue; + int index = FRESHNESS.equals(track.getId()) ? 0 : 1; + age[index] += track.getValue() + food.getAgeRemainder(track.getId()); } - return total; + return age; } private static boolean isBundle(ItemStack item) { diff --git a/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java b/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java index acab011..a824ce0 100644 --- a/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java +++ b/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java @@ -56,8 +56,10 @@ void setUp() { 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)))); + TrackLoader.oList.add(new TagTrack("warmth", true, List.of(new TagStep("hot", "Hot", 0, 1, 1)))); food("beef"); food("pork"); + FoodLoader.getByString("pork").addOrModifyTrack(TrackLoader.getByString("warmth")); now = System.currentTimeMillis(); } @@ -87,6 +89,15 @@ private ItemStack meat(String id, Material type, int amount, int value, long sec return ItemBuilder.stamp(new ItemStack(type, amount), food, id); } + /** Hot pork with the given freshness and warmth, stored now. */ + private ItemStack hotPork(int freshness, int warmth) { + FoodItem food = new FoodItem(FoodLoader.getByString("pork")); + food.getTagTrack("freshness").setValue(freshness); + food.getTagTrack("warmth").forceSetValue(warmth); + food.setLastUpdate(now); + return ItemBuilder.stamp(new ItemStack(Material.PORKCHOP), food, "pork"); + } + private ItemStack beef(int amount, int value, long secondsAgo) { return meat("beef", Material.BEEF, amount, value, secondsAgo); } @@ -155,6 +166,18 @@ void equallyAgedFoodKeepsTheFirstCopy() { assertSame(b, BundleFoodStacker.moreAged(beef(1, 1, 0), b)); } + @Test + void freshnessDecidesWhichIsOlderAndWarmthOnlyBreaksATie() { + ItemStack staleButHot = hotPork(15, 10); + ItemStack freshButCool = hotPork(10, 19); + assertSame(staleButHot, BundleFoodStacker.moreAged(freshButCool, staleButHot)); + assertSame(staleButHot, BundleFoodStacker.moreAged(staleButHot, freshButCool)); + + ItemStack hot = hotPork(10, 5); + ItemStack cooler = hotPork(10, 9); + assertSame(cooler, BundleFoodStacker.moreAged(hot, cooler)); + } + @Test void onlyBundlesWithSomethingToMergeAreRewritten() { assertNull(BundleFoodStacker.stack(null)); From a00a58a2e1ef64c800d5c81b0c463a14e666d7ba Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:05:00 +0200 Subject: [PATCH 4/5] test: make the sub-second milk aging step deterministic The step set the clock to now and relied on at least a millisecond passing before tickAge, so a fast run returned early and left the whole-seconds check uncovered, failing the coverage gate. Use half a second ago, which always reaches it. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java index 551ceaf..30e70c2 100644 --- a/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java @@ -115,7 +115,7 @@ void milkAgingUsesElapsedWholeSecondsAndCarriesScaledFractions() { fill("milk", 2); LiquidContainerState.setMilkSnapshot(barrel.furniture, 3, 7, "Cow"); barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() + 100_000L); LiquidContainerState.tickAge(barrel.furniture); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); - barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis()); + barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() - 500L); LiquidContainerState.tickAge(barrel.furniture); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() - 2_000L); LiquidContainerState.tickAge(barrel.furniture); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); From ac115c16d8b001b447b609190cddd9f4557fbe4d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:27:53 +0200 Subject: [PATCH 5/5] test: age milk containers against a fixed clock in tests The milk aging test set timestamps from the wall clock and relied on how much time passed before tickAge read it again, so a fast or slow run could take another branch or store a different remainder. Add a tickAge overload that takes the current time, which the public method calls with the system clock, and run every step of the test against a fixed time. Co-Authored-By: Claude Opus 5.5 --- .../cooking/liquid/LiquidContainerState.java | 5 ++++- .../liquid/LiquidContainerCoverageTest.java | 17 +++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/liquid/LiquidContainerState.java b/src/main/java/net/tfminecraft/cooking/liquid/LiquidContainerState.java index ec380ec..823087c 100644 --- a/src/main/java/net/tfminecraft/cooking/liquid/LiquidContainerState.java +++ b/src/main/java/net/tfminecraft/cooking/liquid/LiquidContainerState.java @@ -114,10 +114,13 @@ public static void touchLastUpdate(Furniture furniture) { } public static void tickAge(Furniture furniture) { + tickAge(furniture, System.currentTimeMillis()); + } + + static void tickAge(Furniture furniture, long now) { if (!TYPE_MILK.equalsIgnoreCase(getType(furniture)) || isEmpty(furniture)) { return; } - long now = System.currentTimeMillis(); long last = getLastUpdate(furniture); long elapsedMs = now - last; if (elapsedMs <= 0) { diff --git a/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java index 30e70c2..06cace7 100644 --- a/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java @@ -113,15 +113,16 @@ void milkAgingUsesElapsedWholeSecondsAndCarriesScaledFractions() { fill("water", 2); LiquidContainerState.tickAge(barrel.furniture); assertFalse(barrel.variables.containsKey(LiquidContainerState.VAR_LAST_UPDATE)); fill("milk", 0); LiquidContainerState.tickAge(barrel.furniture); assertFalse(barrel.variables.containsKey(LiquidContainerState.VAR_LAST_UPDATE)); fill("milk", 2); LiquidContainerState.setMilkSnapshot(barrel.furniture, 3, 7, "Cow"); - barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() + 100_000L); - LiquidContainerState.tickAge(barrel.furniture); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); - barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() - 500L); - LiquidContainerState.tickAge(barrel.furniture); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); - barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() - 2_000L); - LiquidContainerState.tickAge(barrel.furniture); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); + long now = 1_000_000L; + barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, now + 100_000L); + LiquidContainerState.tickAge(barrel.furniture, now); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); + barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, now - 500L); + LiquidContainerState.tickAge(barrel.furniture, now); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); + barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, now - 2_000L); + LiquidContainerState.tickAge(barrel.furniture, now); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); assertEquals(0.5, (Double) barrel.variables.get(LiquidContainerState.VAR_FRESHNESS_REMAINDER), 0.001); - barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() - 2_000L); - LiquidContainerState.tickAge(barrel.furniture); assertEquals(8, LiquidContainerState.getDairyFreshness(barrel.furniture)); + barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, now - 2_000L); + LiquidContainerState.tickAge(barrel.furniture, now); assertEquals(8, LiquidContainerState.getDairyFreshness(barrel.furniture)); assertEquals(0.0, (Double) barrel.variables.get(LiquidContainerState.VAR_FRESHNESS_REMAINDER), 0.001); }