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/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/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..e6478e3 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java @@ -0,0 +1,73 @@ +package net.tfminecraft.cooking.utils; + +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; + +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, 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. 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. */ + public static boolean isResting(PlacedSlot slot, ItemStack stack) { + Resting resting = RESTING.get(slot); + if (resting == null) { + return false; + } + if (resting.generation() == GENERATION.get() && 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) { + // 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(), generation)); + } 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() { + GENERATION.incrementAndGet(); + } +} 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/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); } } 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; + } +}