From 49de385f715a7d0a9f4ae9291d1b2a5bdd6c75b1 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:41:48 +0100 Subject: [PATCH 1/3] perf: rewrite food on meat hooks, baking trays and troughs only when it would look different The meat hook, baking tray and trough timers rebuilt and rewrote every food in their slots each second (model, lore, carve state, display item, hook display transform, furniture save) only to store the new clock and aged numbers. On Main at 15+ players that was ~11 s of main thread per hour (meat hooks alone 5.5 s/h). Food ages by the wall clock from its stored clock, values and remainder, so the slots now use the inventory scan's rule (FoodScan, #124): SlotAging writes a slot only for a new tag step, lost warmth, broken lore, a clock to set, or a rebuild that would differ (with the furniture's model), and otherwise leaves it resting until a second before it can next look different. Any change to the item ends the rest, as do /cooking reload and /iareload (ItemRef.clearCache). Food with a sauce keeps the old every-second rewrite. Carving reads the roast's numbers into the cuts and starts the roast left on the hook on a new clock, so it now ages the roast to now first; before, those numbers were at most a second old. Plates keep the old rewrite: adding a sauce copies the plated food onto a new clock and sauces only age when the food is rewritten. Co-Authored-By: Claude Opus 5.5 --- .../cooking/baking/BakingTrayAging.java | 6 +- .../cooking/carve/CarveHandler.java | 4 + .../cooking/hook/MeatHookHandler.java | 7 +- .../cooking/trough/TroughAging.java | 7 +- .../tfminecraft/cooking/utils/FoodScan.java | 21 +- .../tfminecraft/cooking/utils/ItemRef.java | 6 +- .../tfminecraft/cooking/utils/SlotAging.java | 66 +++++ .../baking/BakingTrayCoverageTest.java | 14 ++ .../carve/CarvingAndMeatHookCoverageTest.java | 23 ++ .../cooking/trough/TroughCoverageTest.java | 13 + .../cooking/utils/SlotAgingTest.java | 229 ++++++++++++++++++ 11 files changed, 380 insertions(+), 16 deletions(-) create mode 100644 src/main/java/net/tfminecraft/cooking/utils/SlotAging.java create mode 100644 src/test/java/net/tfminecraft/cooking/utils/SlotAgingTest.java diff --git a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java index e2b987e..39f9d65 100644 --- a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java +++ b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java @@ -12,7 +12,7 @@ import net.tfminecraft.cooking.Cooking; import net.tfminecraft.cooking.heat.HeatSources; import net.tfminecraft.cooking.item.FoodItem; -import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.SlotAging; import net.tfminecraft.interactiblefurniture.furniture.Furniture; import net.tfminecraft.interactiblefurniture.furniture.PlacedFurnitureSlot; import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; @@ -81,7 +81,7 @@ private void ageTray(Furniture tray, BakingTrayRecipe recipe) { private boolean ageSlot(PlacedSlot slot, Furniture tray) { ItemStack item = slot.getCurrentItem(); - if (item == null || item.getType().isAir()) { + if (item == null || item.getType().isAir() || SlotAging.isResting(slot, item)) { return false; } @@ -90,7 +90,7 @@ private boolean ageSlot(PlacedSlot slot, Furniture tray) { return false; } - ItemStack updated = ItemUpdater.updateItem(item, foodItem, tray.getId()); + ItemStack updated = SlotAging.age(slot, item, foodItem, tray.getId()); if (updated == null || updated.getType().isAir()) { return false; } diff --git a/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java b/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java index 97c9579..555a78c 100644 --- a/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java +++ b/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java @@ -78,6 +78,10 @@ public static boolean tryCarve(Player player, Furniture furniture, PlacedSlot sl if (roast == null) return false; + // The hook only rewrites a roast when it looks different, so bring its aging up to now before the cuts and + // the roast left on the hook (which starts a new clock) take its numbers. + roast.updateAge(); + CarvableRoastUtils.readCarveState(roast, stack); if (!CarvableRoastUtils.isCarvable(roast)) return false; diff --git a/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java b/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java index f91ecc7..4c8dade 100644 --- a/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java +++ b/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java @@ -17,7 +17,7 @@ import net.tfminecraft.cooking.carve.CarvableRoastUtils; import net.tfminecraft.cooking.carve.CarveHandler; import net.tfminecraft.cooking.item.FoodItem; -import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.SlotAging; import net.tfminecraft.cooking.utils.StationChunks; import net.tfminecraft.interactiblefurniture.events.FurniturePunchEvent; import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemAddEvent; @@ -107,14 +107,15 @@ private static void tickHook(Furniture furniture) { return; } ItemStack stack = slot.getCurrentItem(); - if (stack == null) { + // An unchanged roast that can't look different yet keeps its item and display as they are. + if (stack == null || SlotAging.isResting(slot, stack)) { return; } FoodItem foodItem = FoodItem.fromItem(stack); if (foodItem == null) { return; } - ItemStack updated = ItemUpdater.updateItem(stack, foodItem, furniture.getId()); + ItemStack updated = SlotAging.age(slot, stack, foodItem, furniture.getId()); if (updated == null || updated.getType().isAir()) { return; } diff --git a/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java b/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java index 2818dd4..e4273e7 100644 --- a/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java +++ b/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java @@ -10,7 +10,7 @@ import net.tfminecraft.cooking.Cooking; import net.tfminecraft.cooking.cache.FurnitureCache; import net.tfminecraft.cooking.item.FoodItem; -import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.SlotAging; import net.tfminecraft.interactiblefurniture.furniture.Furniture; import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; @@ -59,7 +59,8 @@ private void ageTrough(Furniture furniture) { private boolean ageSlot(Furniture furniture, PlacedSlot slot, String slotId) { ItemStack item = slot.getCurrentItem(); - if (item == null || item.getType().isAir()) { + // A resting food was neither rotten nor unknown when last aged, and it can't have gone off since. + if (item == null || item.getType().isAir() || SlotAging.isResting(slot, item)) { return false; } @@ -69,7 +70,7 @@ private boolean ageSlot(Furniture furniture, PlacedSlot slot, String slotId) { return true; } - ItemStack updated = ItemUpdater.updateItem(item, foodItem, furniture.getId()); + ItemStack updated = SlotAging.age(slot, item, foodItem, furniture.getId()); if (updated != null && updated.getType().isAir()) { updated = null; } diff --git a/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java b/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java index 68c5351..f53b9f7 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java +++ b/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java @@ -18,7 +18,8 @@ * remainder, so writing the aged numbers back changes nothing a player sees and the food ages the same either way. * The scan therefore only rewrites a food that would look different (a new tag step, warmth gone, lore or model built * differently, as after a reload or an update), whose clock must be set, or that has a sauce (below). It also says - * when the unchanged food could next look different, so TLibs can skip the slot until then. + * when the unchanged food could next look different, so TLibs can skip the slot until then. SlotAging does the same + * for food lying in furniture. */ public final class FoodScan { @@ -37,18 +38,26 @@ public record Result(ItemStack item, long recheckAt) {} * Ages the food (parsed from stack, whose meta is meta) to now. A held food is only rewritten when today's held * rule says so (a new step, warmth gone or broken lore), so eating it isn't interrupted. */ + public static Result update(ItemStack stack, ItemMeta meta, FoodItem fi, boolean held) { + return update(stack, meta, fi, held, null); + } + + /** + * As {@link #update(ItemStack, ItemMeta, FoodItem, boolean)} for food in a slot of the furniture whose id is + * furniture (null in an inventory), which picks the model the food is built with there. + */ // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - public static Result update(ItemStack stack, ItemMeta meta, FoodItem fi, boolean held) { + public static Result update(ItemStack stack, ItemMeta meta, FoodItem fi, boolean held, String furniture) { PersistentDataContainer pdc = meta.getPersistentDataContainer(); boolean broken = ItemUpdater.needsLoreRebuild(meta.getLore(), pdc.has(Keys.LORE_INDEX_MAP, PersistentDataType.STRING)); if (!fi.shouldUpdate()) { - return new Result(broken ? ItemUpdater.applyItemUpdate(stack, fi, null) : null, ItemScanHandler.WHEN_CHANGED); + return new Result(broken ? ItemUpdater.applyItemUpdate(stack, fi, furniture) : null, ItemScanHandler.WHEN_CHANGED); } if (fi.hasSauce()) { // A sauce keeps no clock or remainder of its own, so it only ages by whole steps each time the food is // rewritten. Rewrite it on every scan as before, so sauces age exactly as they always have. - return new Result(ItemUpdater.updateItem(stack, fi, null, held), ItemScanHandler.EVERY_SCAN); + return new Result(ItemUpdater.updateItem(stack, fi, furniture, held), ItemScanHandler.EVERY_SCAN); } long now = StackNormalizer.quantizedNow(); Long stored = pdc.get(Keys.LAST_UPDATE, PersistentDataType.LONG); @@ -58,12 +67,12 @@ public static Result update(ItemStack stack, ItemMeta meta, FoodItem fi, boolean fi.setLastUpdate(now); long recheckAt = held ? ItemScanHandler.EVERY_SCAN : nextChange(fi, now); if (changed || expired || broken || (!held && clockNeedsWriting(stored, now))) { - return new Result(ItemUpdater.applyItemUpdate(stack, fi, null), recheckAt); + return new Result(ItemUpdater.applyItemUpdate(stack, fi, furniture), recheckAt); } if (held) { return new Result(null, recheckAt); } - ItemStack rebuilt = ItemUpdater.applyItemUpdate(stack.clone(), fi, null); + ItemStack rebuilt = ItemUpdater.applyItemUpdate(stack.clone(), fi, furniture); return new Result(looksTheSame(stack, meta, rebuilt) ? null : rebuilt, recheckAt); } diff --git a/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java b/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java index 52db17d..7299588 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java +++ b/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java @@ -51,9 +51,13 @@ public static ItemStack apply(String ref, ItemStack source) { return out; } - /** Drops every kept template; the next use of each ref builds it again. */ + /** + * Drops every kept template; the next use of each ref builds it again. Runs on /cooking reload and /iareload, so + * food resting in furniture slots is looked at again too: it may now be built differently. + */ public static void clearCache() { templates = null; + SlotAging.forgetAll(); } /** Turns template keeping on or off; either way the kept templates are dropped. */ diff --git a/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java b/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java new file mode 100644 index 0000000..19ccd7b --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java @@ -0,0 +1,66 @@ +package net.tfminecraft.cooking.utils; + +import java.util.Map; +import java.util.WeakHashMap; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; +import net.tfminecraft.tlibs.itemscan.ItemScanHandler; + +/** + * Ages the food lying in a furniture slot (a meat hook, a baking tray, a trough) on its station's one-second timer, + * the way the inventory scan ages unheld food (FoodScan). The timers used to rebuild and rewrite every such food each + * second only to store its new clock and aged numbers; the food ages the same from its stored clock, values and + * remainder, so the slot is now only written when the food would look different. + * + *

Like TLibs' scan, it keeps a copy of each slot's food that it left as it was, and when that food can next look + * different. While the slot holds an equal item and that time hasn't come, the timer skips the slot. Any change to the + * item (taken, replaced, carved, glazed, changed in place) ends the skip, as does {@link #forgetAll()}. + */ +public final class SlotAging { + + /** A slot's food as the timer last left it, and when it can next look different. */ + private record Resting(ItemStack item, long until) {} + + // By slot identity; a slot dropped by its furniture drops out on its own. + private static final Map RESTING = new WeakHashMap<>(); + + private SlotAging() {} + + /** True while the slot still holds the item the timer last left there and that item can't look different yet. */ + public static boolean isResting(PlacedSlot slot, ItemStack stack) { + Resting resting = RESTING.get(slot); + if (resting == null) { + return false; + } + if (resting.until() > StackNormalizer.quantizedNow() && resting.item().equals(stack)) { + return true; + } + RESTING.remove(slot); + return false; + } + + /** + * Ages the food (parsed from stack) in a slot of the furniture whose id is furniture, and returns what to put in + * the slot, or null to leave it. A food left as it was rests until it can look different; a written one is looked + * at again on the next pass, as the scan does. + */ + public static ItemStack age(PlacedSlot slot, ItemStack stack, FoodItem food, String furniture) { + ItemMeta meta = stack.getItemMeta(); + FoodScan.Result result = FoodScan.update(stack, meta, food, false, furniture); + if (result.item() == null && result.recheckAt() != ItemScanHandler.EVERY_SCAN) { + RESTING.put(slot, new Resting(stack.clone(), result.recheckAt())); + } else { + RESTING.remove(slot); + } + return result.item(); + } + + /** Ends every skip, so each slot's food is looked at again: its config or item templates may have changed. */ + public static void forgetAll() { + RESTING.clear(); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java b/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java index 45c0d4a..6a85830 100644 --- a/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java @@ -39,6 +39,7 @@ import net.tfminecraft.cooking.utils.InventoryAdder; import net.tfminecraft.cooking.utils.ItemBuilder; import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.SlotAging; import net.tfminecraft.cooking.utils.WarmthUtils; import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; import net.tfminecraft.interactiblefurniture.furniture.Furniture; @@ -413,6 +414,15 @@ void agingVisitsLooseAndNestedTraysOnceAndIgnoresUnsupportedContents() { verify(nested, times(1)).forceModel(any()); verify(separate, times(1)).forceModel(any()); } + @Test + void restingBreadIsLeftAloneUntilItCanLookDifferent() { + ItemStack original = bakery.loaf(0, 1); PlacedSlot slot = tray.place("left_a", original); + bakery.aging.when(() -> SlotAging.isResting(slot, original)).thenReturn(true); + new BakingTrayAging().start(); env.server.getScheduler().performTicks(20); + assertSame(original, slot.getCurrentItem()); verify(slot, never()).forceModel(any()); + bakery.aging.verify(() -> SlotAging.age(any(), any(), any(), any()), never()); verify(env.manager, never()).markDirty(tray.furniture); + } + @Test void emptyEntriesInThePublicSlotMapDoNotPreventOtherLoavesFromAging() { tray.furniture.getActiveSlots().put("left_a", null); @@ -430,6 +440,7 @@ public static final class Bakery implements AutoCloseable { public final Set consumers = new HashSet<>(); public final Map> nested = new LinkedHashMap<>(); public final MockedStatic updater; + public final MockedStatic aging; public final MockedStatic heat; public final Station tray; public BakingTrayRecipe recipe; @@ -445,6 +456,9 @@ public Bakery() { updater = env.scoped(mockStatic(ItemUpdater.class)); updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(0), inv.getArgument(1))); updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(0), inv.getArgument(1))); + // The timer ages through SlotAging; route it to the stubbed ItemUpdater (SlotAgingTest covers the real one). + aging = env.scoped(mockStatic(SlotAging.class)); + aging.when(() -> SlotAging.age(any(), any(), any(), any())).thenAnswer(inv -> ItemUpdater.updateItem(inv.getArgument(1), inv.getArgument(2), inv.getArgument(3))); heat = env.scoped(mockStatic(HeatSources.class)); heat.when(() -> HeatSources.isConsumer(any())).thenAnswer(inv -> consumers.contains(inv.getArgument(0))); heat.when(() -> HeatSources.consumerHasHeat(any())).thenAnswer(inv -> heated); diff --git a/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java b/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java index 4c72b47..f0f8bf2 100644 --- a/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java @@ -56,6 +56,7 @@ import net.tfminecraft.cooking.utils.ItemBuilder; import net.tfminecraft.cooking.utils.ItemRef; import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.SlotAging; import net.tfminecraft.cooking.utils.Keys; import net.tfminecraft.interactiblefurniture.events.FurniturePunchEvent; import net.tfminecraft.interactiblefurniture.furniture.Furniture; @@ -71,6 +72,7 @@ class CarvingAndMeatHookCoverageTest { MockedStatic parser; MockedStatic refs; MockedStatic updater; + MockedStatic aging; String oldHook, oldTool; FoodItem lastPart; @@ -97,6 +99,9 @@ class CarvingAndMeatHookCoverageTest { updater = env.scoped(mockStatic(ItemUpdater.class)); updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(1))); updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(1))); + // The hook's timer ages through SlotAging; route it to the stubbed ItemUpdater (SlotAgingTest covers the real one). + aging = env.scoped(mockStatic(SlotAging.class)); + aging.when(() -> SlotAging.age(any(), any(), any(), any())).thenAnswer(inv -> ItemUpdater.updateItem(inv.getArgument(1), inv.getArgument(2), inv.getArgument(3))); load(""" roast: start-remaining: 3 @@ -412,6 +417,24 @@ void meatHookTimersAgeOnlyReadableRoastsAndRefreshTheirStageDisplay() { verify(slot).setCurrentItem(any()); verify(slot).applyDisplayData(any()); verify(env.manager).markDirty(hook.furniture); } + @Test + void carvingAgesTheRoastToNowBeforeTheCutsTakeItsNumbers() { + // The hook leaves a resting roast's stored numbers alone, so they can be well behind the clock. + FoodItem roast = roast("roast"); track(roast, "freshness", 0); roast.setLastUpdate(System.currentTimeMillis() - 5_000L); + PlacedSlot slot = hook.place("content", whole(roast)); + assertTrue(carve(slot)); + assertTrue(lastPart.getTagTrack("freshness").getValue() >= 5, "the cut keeps the roast's five seconds of aging"); + } + + @Test + void aRestingRoastKeepsItsItemAndDisplayUntilItCanLookDifferent() { + PlacedSlot slot = placeRoast(); ItemStack original = slot.getCurrentItem(); + aging.when(() -> SlotAging.isResting(slot, original)).thenReturn(true); + hooks.start(); env.server.getScheduler().performTicks(20); + assertSame(original, slot.getCurrentItem()); verify(slot, never()).setCurrentItem(any()); verify(slot, never()).applyDisplayData(any()); + aging.verify(() -> SlotAging.age(any(), any(), any(), any()), never()); verify(env.manager, never()).markDirty(hook.furniture); + } + @Test void airAgingResultsCannotReplaceAHangingRoast() { PlacedSlot slot = placeRoast(); ItemStack original = slot.getCurrentItem(); diff --git a/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java b/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java index c53cd9e..9d4f662 100644 --- a/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java @@ -25,6 +25,7 @@ import net.tfminecraft.cooking.utils.IngredientConverter; import net.tfminecraft.cooking.utils.InventoryAdder; import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.SlotAging; class TroughCoverageTest { Kitchen kitchen; @@ -32,6 +33,7 @@ class TroughCoverageTest { Station trough; TroughHandler handler; MockedStatic converter; + MockedStatic aging; String previousTrough, previousFeed; int previousCount; @@ -43,6 +45,9 @@ class TroughCoverageTest { when(env.api.getCreator().getItemFromPath("feed")).thenAnswer(inv -> new ItemStack(Material.WHEAT)); converter = env.scoped(mockStatic(IngredientConverter.class)); converter.when(() -> IngredientConverter.convertIfNeeded(any(), any())).thenAnswer(inv -> inv.getArgument(1)); + // The timer ages through SlotAging; route it to the kitchen's stubbed ItemUpdater (SlotAgingTest covers the real one). + aging = env.scoped(mockStatic(SlotAging.class)); + aging.when(() -> SlotAging.age(any(), any(), any(), any())).thenAnswer(inv -> ItemUpdater.updateItem(inv.getArgument(1), inv.getArgument(2), inv.getArgument(3))); } @AfterEach void close() { kitchen.close(); FurnitureCache.trough = previousTrough; ItemCache.troughFeed = previousFeed; ItemCache.troughItemsPerClick = previousCount; @@ -132,6 +137,14 @@ void invalidBatchAmountsNeverConsumeOrStoreFood(int count) { trough.place("a", new ItemStack(Material.STONE)); when(trough.furniture.getLoc()).thenReturn(new Location(null, 0, 0, 0)); env.server.getScheduler().performTicks(20); assertFalse(trough.active.containsKey("a")); assertEquals(1, env.dropped.size()); } + @Test void restingFoodIsLeftAloneUntilItCanLookDifferent() { + ItemStack original = env.stack(carrot(), Material.CARROT, 3); var slot = trough.place("a", original); + aging.when(() -> SlotAging.isResting(slot, original)).thenReturn(true); + new TroughAging().start(); env.server.getScheduler().performTicks(20); + assertSame(original, slot.getCurrentItem()); verify(slot, never()).forceModel(any()); assertTrue(env.dropped.isEmpty()); + aging.verify(() -> SlotAging.age(any(), any(), any(), any()), never()); verify(env.manager, never()).markDirty(trough.furniture); + } + @Test void failedAgingRenderingNeverDeletesStoredFood() { ItemStack original = env.stack(carrot(), Material.CARROT, 3); var slot = trough.place("a", original); kitchen.updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(new ItemStack(Material.AIR)); diff --git a/src/test/java/net/tfminecraft/cooking/utils/SlotAgingTest.java b/src/test/java/net/tfminecraft/cooking/utils/SlotAgingTest.java new file mode 100644 index 0000000..9e72efc --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/utils/SlotAgingTest.java @@ -0,0 +1,229 @@ +package net.tfminecraft.cooking.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +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.mockito.MockedStatic; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.model.FoodModel; +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.interactiblefurniture.furniture.PlacedSlot; + +class SlotAgingTest { + static final String HOOK = "meat_hook"; + + Cooking plugin; + List foods; + List tracks; + MockedStatic clock; + PlacedSlot slot; + long now; + + @BeforeEach + void setup() { + MockBukkit.mock(); + plugin = 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("aging", "Aging", 4, .9, .9), + new TagStep("stale", "Stale", 9, .7, .7)))); + TrackLoader.oList.add(new TagTrack("warmth", true, List.of( + new TagStep("hot", "Hot", 0, 1.2, 1.2), + new TagStep("warm", "Warm", 3, 1.1, 1.1), + new TagStep("cold", "Cold", 20, 1, 1)))); + clock = mockStatic(StackNormalizer.class, CALLS_REAL_METHODS); + clock.when(StackNormalizer::quantizedNow).thenAnswer(inv -> now); + slot = mock(PlacedSlot.class); + SlotAging.forgetAll(); + } + + @AfterEach + void cleanup() { + SlotAging.forgetAll(); + clock.close(); + FoodLoader.oList.clear(); + FoodLoader.oList.addAll(foods); + TrackLoader.oList.clear(); + TrackLoader.oList.addAll(tracks); + MockBukkit.unmock(); + Cooking.plugin = plugin; + } + + /** + * The hook, tray and trough timers used to rewrite their food every second (ItemUpdater.updateItem). Skipping + * resting slots must leave food that looks exactly the same after every second, through steps and warmth loss, + * with one write per visible change (plus setting a clock that started off the second once). + */ + @Test + void restingSlotsLookExactlyLikeRewritingEverySecond() { + for (long start : new long[] {10_000L, 10_300L}) { + SlotAging.forgetAll(); + ItemStack old = warmRoast(start); + ItemStack aged = old.clone(); + int writes = 0; + int visibleChanges = 0; + now = 10_000L; + for (int second = 0; second < 100; second++) { + now += 1000L; + List loreBefore = lore(old); + ItemStack rewritten = ItemUpdater.updateItem(old, FoodItem.fromItem(old), HOOK); + if (rewritten != null) old = rewritten; + if (!lore(old).equals(loreBefore)) visibleChanges++; + if (!SlotAging.isResting(slot, aged)) { + ItemStack written = SlotAging.age(slot, aged, FoodItem.fromItem(aged), HOOK); + if (written != null) { + aged = written; + writes++; + } + } + assertEquals(lore(old), lore(aged), "at " + now); + assertEquals(steps(old), steps(aged), "at " + now); + } + assertTrue(visibleChanges >= 3, "the roast should go aging, warm, stale and lose its warmth"); + assertEquals(visibleChanges + (start % 1000 == 0 ? 0 : 1), writes); + assertTrue(SlotAging.isResting(slot, aged), "stale, cold food never changes again"); + } + } + + @Test + void aRestEndsWhenTheItemChangesOrItsTimeComes() { + now = 10_000L; + ItemStack roast = warmRoast(now); + assertFalse(SlotAging.isResting(slot, roast)); + now = 11_000L; + assertNull(SlotAging.age(slot, roast, FoodItem.fromItem(roast), HOOK)); + assertTrue(SlotAging.isResting(slot, roast)); + assertTrue(SlotAging.isResting(slot, roast.clone()), "an equal item rests too"); + assertFalse(SlotAging.isResting(mock(PlacedSlot.class), roast), "rests are per slot"); + + ItemStack two = roast.clone(); + two.setAmount(2); + assertFalse(SlotAging.isResting(slot, two), "another item ends the rest"); + assertFalse(SlotAging.isResting(slot, roast), "and it stays ended"); + + assertNull(SlotAging.age(slot, roast, FoodItem.fromItem(roast), HOOK)); + // Started at 10 s, it goes aging at 18 s (warm at 22 s): due from 17 s, a second early. + now = 16_000L; + assertTrue(SlotAging.isResting(slot, roast)); + now = 17_000L; + assertFalse(SlotAging.isResting(slot, roast)); + } + + @Test + void writtenFoodIsLookedAtAgainOnTheNextPass() { + now = 11_000L; + ItemStack offTheSecond = warmRoast(10_300L); + ItemStack written = SlotAging.age(slot, offTheSecond, FoodItem.fromItem(offTheSecond), HOOK); + assertNotNull(written); + assertFalse(SlotAging.isResting(slot, written)); + assertNull(SlotAging.age(slot, written, FoodItem.fromItem(written), HOOK)); + assertTrue(SlotAging.isResting(slot, written)); + } + + @Test + void foodIsRebuiltWithTheFurnituresModel() { + now = 11_000L; + ItemStack offTheSecond = warmRoast(10_300L); + ItemStack resting = warmRoast(11_000L); + try (MockedStatic updater = mockStatic(ItemUpdater.class, CALLS_REAL_METHODS)) { + SlotAging.age(slot, offTheSecond, FoodItem.fromItem(offTheSecond), HOOK); + SlotAging.age(slot, resting, FoodItem.fromItem(resting), HOOK); + updater.verify(() -> ItemUpdater.applyItemUpdate(any(), any(), eq(HOOK)), times(2)); + updater.verify(() -> ItemUpdater.applyItemUpdate(any(), any(), isNull()), never()); + } + } + + @Test + void sauceFoodIsRewrittenEveryPassAndNeverRests() { + FoodItem dish = new FoodItem(food("plate", new MemoryConfiguration())); + FoodItem gravy = new FoodItem(food("gravy", new MemoryConfiguration())); + gravy.addOrModifyTrack(TrackLoader.getByString("freshness")); + dish.setSauce(gravy); + dish.setLastUpdate(10_000L); + ItemStack stack = ItemBuilder.stamp(new ItemStack(Material.BOWL), dish, "Plate"); + now = 12_000L; + ItemStack written; + try (MockedStatic updater = mockStatic(ItemUpdater.class, CALLS_REAL_METHODS)) { + written = SlotAging.age(slot, stack, FoodItem.fromItem(stack), HOOK); + updater.verify(() -> ItemUpdater.updateItem(any(), any(), eq(HOOK), eq(false))); + } + assertNotNull(written); + assertFalse(SlotAging.isResting(slot, written)); + assertNull(SlotAging.age(slot, written, FoodItem.fromItem(written), HOOK), "nothing new this second"); + assertFalse(SlotAging.isResting(slot, written), "but it is looked at again next second"); + } + + @Test + void foodThatNeverAgesRestsUntilItChangesAndReloadsEndEveryRest() { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("update", false); + FoodItem salt = food("salt", config); + ItemStack stack = ItemBuilder.stamp(new ItemStack(Material.SUGAR), new FoodItem(salt), "Salt"); + now = 5_000L; + assertNull(SlotAging.age(slot, stack, FoodItem.fromItem(stack), HOOK)); + now = Long.MAX_VALUE - 1; + assertTrue(SlotAging.isResting(slot, stack)); + ItemRef.clearCache(); + assertFalse(SlotAging.isResting(slot, stack), "a reload or /iareload ends every rest"); + } + + /** Fresh, hot roast whose clock reads start. Freshness ages at 2 s a step, warmth at 4 s. */ + private ItemStack warmRoast(long start) { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("age.freshness", 2.0); + config.set("age.warmth", 4.0); + FoodItem roast = new FoodItem(food("roast", config)); + roast.addOrModifyTrack(TrackLoader.getByString("freshness")); + roast.addOrModifyTrack(TrackLoader.getByString("warmth")); + roast.setLastUpdate(start); + // Built food always has a model; without one, the first rewrite would add it. + roast.setModel(new FoodModel(new ItemStack(Material.COOKED_BEEF))); + return ItemBuilder.stamp(new ItemStack(Material.COOKED_BEEF), roast, "Roast"); + } + + @SuppressWarnings("deprecation") + private static List lore(ItemStack stack) { + ItemMeta meta = stack.getItemMeta(); + return meta.getLore(); + } + + private static List steps(ItemStack stack) { + List steps = new ArrayList<>(); + for (TagTrack track : FoodItem.fromItem(stack).getTagTracks()) { + steps.add(track.getId() + "=" + track.getCurrentStep().getId()); + } + return steps; + } + + private FoodItem food(String id, MemoryConfiguration config) { + config.set("name", id); + FoodItem food = new FoodItem(id, config); + FoodLoader.oList.removeIf(existing -> existing.getId().equals(id)); + FoodLoader.oList.add(food); + return food; + } +} From ff50f9147e8e6480f72ea6e35ae0637029bb0e6c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:07:11 +0100 Subject: [PATCH 2/3] Age a glazed roast to now explicitly and let /iareload end rests from any thread Review: glazing relied on addOrModifyTrack aging the roast first; say it outright like carving does. forgetAll may run from the ItemsAdder load event, so it now moves a generation on instead of clearing the server thread's map. Co-Authored-By: Claude Opus 5.5 --- .../cooking/glaze/GlazeHandler.java | 2 ++ .../tfminecraft/cooking/utils/SlotAging.java | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/glaze/GlazeHandler.java b/src/main/java/net/tfminecraft/cooking/glaze/GlazeHandler.java index 6232784..4ca923f 100644 --- a/src/main/java/net/tfminecraft/cooking/glaze/GlazeHandler.java +++ b/src/main/java/net/tfminecraft/cooking/glaze/GlazeHandler.java @@ -48,6 +48,8 @@ public void onInteract(FurnitureInteractEvent e) { p.sendMessage("§cThere's no roast here to glaze."); return; } + // The hook only rewrites a resting roast when it looks different, so bring its numbers up to now first. + roast.updateAge(); if (!GlazeConfig.isRoast(roast)) { p.sendMessage("§cOnly roasts can be glazed."); return; diff --git a/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java b/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java index 19ccd7b..e6478e3 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java +++ b/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java @@ -2,6 +2,7 @@ import java.util.Map; import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicInteger; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; @@ -22,12 +23,15 @@ */ public final class SlotAging { - /** A slot's food as the timer last left it, and when it can next look different. */ - private record Resting(ItemStack item, long until) {} + /** A slot's food as the timer last left it, when it can next look different, and the generation it rests in. */ + private record Resting(ItemStack item, long until, int generation) {} - // By slot identity; a slot dropped by its furniture drops out on its own. + // By slot identity; a slot dropped by its furniture drops out on its own. Only the server thread touches it. private static final Map RESTING = new WeakHashMap<>(); + // forgetAll can come from an ItemsAdder event on any thread, so it only moves this on and rests from before end. + private static final AtomicInteger GENERATION = new AtomicInteger(); + private SlotAging() {} /** True while the slot still holds the item the timer last left there and that item can't look different yet. */ @@ -36,7 +40,8 @@ public static boolean isResting(PlacedSlot slot, ItemStack stack) { if (resting == null) { return false; } - if (resting.until() > StackNormalizer.quantizedNow() && resting.item().equals(stack)) { + if (resting.generation() == GENERATION.get() && resting.until() > StackNormalizer.quantizedNow() + && resting.item().equals(stack)) { return true; } RESTING.remove(slot); @@ -49,10 +54,12 @@ public static boolean isResting(PlacedSlot slot, ItemStack stack) { * at again on the next pass, as the scan does. */ public static ItemStack age(PlacedSlot slot, ItemStack stack, FoodItem food, String furniture) { + // Read first: a rest must not outlive a forgetAll that came while this food was being built. + int generation = GENERATION.get(); ItemMeta meta = stack.getItemMeta(); FoodScan.Result result = FoodScan.update(stack, meta, food, false, furniture); if (result.item() == null && result.recheckAt() != ItemScanHandler.EVERY_SCAN) { - RESTING.put(slot, new Resting(stack.clone(), result.recheckAt())); + RESTING.put(slot, new Resting(stack.clone(), result.recheckAt(), generation)); } else { RESTING.remove(slot); } @@ -61,6 +68,6 @@ public static ItemStack age(PlacedSlot slot, ItemStack stack, FoodItem food, Str /** Ends every skip, so each slot's food is looked at again: its config or item templates may have changed. */ public static void forgetAll() { - RESTING.clear(); + GENERATION.incrementAndGet(); } } From 6ce18c1f8204a17a497476e63ab2b6ae044d8b60 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:24:27 +0100 Subject: [PATCH 3/3] Keep glazing as it was and save oven bake time with the autosave Review: aging the roast before the glaze checks changed fire-pit glazing (a roast gone off by the clock was refused); addOrModifyTrack already ages it before the glaze track goes on, as before. The tray timer used to mark baking trays dirty every second, which also saved the oven's per-slot bake time with the minute autosave; the oven now marks the tray itself while that time runs. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/cooking/glaze/GlazeHandler.java | 2 -- .../net/tfminecraft/cooking/oven/OvenCavityManager.java | 2 ++ .../net/tfminecraft/cooking/oven/OvenCoverageTest.java | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/glaze/GlazeHandler.java b/src/main/java/net/tfminecraft/cooking/glaze/GlazeHandler.java index 4ca923f..6232784 100644 --- a/src/main/java/net/tfminecraft/cooking/glaze/GlazeHandler.java +++ b/src/main/java/net/tfminecraft/cooking/glaze/GlazeHandler.java @@ -48,8 +48,6 @@ public void onInteract(FurnitureInteractEvent e) { p.sendMessage("§cThere's no roast here to glaze."); return; } - // The hook only rewrites a resting roast when it looks different, so bring its numbers up to now first. - roast.updateAge(); if (!GlazeConfig.isRoast(roast)) { p.sendMessage("§cOnly roasts can be glazed."); return; diff --git a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java index b1da9a6..b97b03b 100644 --- a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java +++ b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java @@ -104,6 +104,8 @@ private void tickTray(Furniture consumer, Furniture tray, BakingTrayRecipe recip } BakingTrayState.incrementSlotElapsed(tray, slotId); + // Save the bake time with the next autosave: the tray's aging timer no longer rewrites it every second. + changed = true; int elapsed = BakingTrayState.getSlotElapsed(tray, slotId); int cookedValue = cooked == null ? RAW : cooked.getValue(); diff --git a/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java b/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java index 47b45df..9138397 100644 --- a/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java @@ -327,7 +327,8 @@ void cavityClocksPauseWithoutHeatThenCookAndBurnEachLoafAtItsOwnThreshold() { assertEquals(2, env.resolve(bakery.tray.active.get("left_b").getCurrentItem()).getTagTrack("cooked").getValue()); assertEquals(0, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "right_a")); assertEquals(0, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "right_b")); - verify(env.manager, times(2)).markDirty(bakery.tray.furniture); + // Once per heated second: the bake times advance and are saved with them. + verify(env.manager, times(4)).markDirty(bakery.tray.furniture); } @Test @@ -374,6 +375,8 @@ void failedBakingRenderDoesNotReplaceTheDisplayedLoafOrClaimASuccessfulChange() bakery.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); new OvenCavityManager().start(); env.server.getScheduler().performTicks(40); assertSame(original, slot.getCurrentItem()); verify(slot, never()).forceModel(any()); - verify(env.manager, never()).markDirty(bakery.tray.furniture); + // Only the bake time advanced (and is saved); the loaf itself stays as it was. + assertEquals(2, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "left_a")); + verify(env.manager, times(2)).markDirty(bakery.tray.furniture); } }