From 96efb3541504a629a9c002d36f3b1ef312da19e0 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:56:58 +0100 Subject: [PATCH] perf: rewrite scanned food only when it would look different The inventory scan rebuilt and rewrote every unheld food stack each time it scanned its owner (about every 4 s on Main), only to store the new clock and aged numbers. Food ages by the wall clock from its stored clock, values and remainder, so leaving those alone ages it exactly the same and nothing a player sees changes. TagManager now hands TLibs a scan() answer: - It reads the item's meta once and parses the food once. - FoodScan ages it to now and writes the slot only for a new tag step, lost warmth or broken lore (the old rules, also used for held food), a clock that is missing, ahead or not on a whole second (set once, as the first rewrite used to), or a rebuild that would differ in anything but the clock data (lore or model after a reload or update). - It tells TLibs when the unchanged food can next show a new step or lose its warmth (a second early), so the slot is skipped until then. Held food is checked every scan, as it was; non-food waits for the item to change. - Food with a sauce keeps the old rewrite on every scan: a sauce stores no clock or remainder of its own, so it only ages when the food is rewritten and changing that would change how fast sauces age. - LegacyFishScan only checks matching fish every scan, other slots wait for their item to change. - /cooking reload asks TLibs to rescan every slot so new config shows at once. Needs TLibs 2.7.0 (ItemScanHandler.scan and ItemScanService.rescanAll); the pin is bumped from 2.6.0. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- .../java/net/tfminecraft/cooking/Cooking.java | 4 + .../cooking/fishing/LegacyFishScan.java | 10 + .../tfminecraft/cooking/item/FoodItem.java | 6 +- .../cooking/manager/TagManager.java | 38 ++- .../tfminecraft/cooking/utils/FoodScan.java | 144 +++++++++ .../cooking/CookingLifecycleTest.java | 5 + .../fishing/LegacyFishScanSkipTest.java | 32 ++ .../CookingAndTagManagersCoverageTest.java | 31 -- .../cooking/manager/TagManagerScanTest.java | 115 ++++++++ .../cooking/utils/FoodScanTest.java | 278 ++++++++++++++++++ 11 files changed, 616 insertions(+), 49 deletions(-) create mode 100644 src/main/java/net/tfminecraft/cooking/utils/FoodScan.java create mode 100644 src/test/java/net/tfminecraft/cooking/fishing/LegacyFishScanSkipTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/manager/TagManagerScanTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/utils/FoodScanTest.java diff --git a/pom.xml b/pom.xml index 9a92301..f91fba9 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ 0.3.0 2.16.0 4.0.0 - 2.6.0 + 2.7.0 diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index 2c1078a..d1cb93c 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -310,6 +310,10 @@ public void loadConfigs() { public void reloadAll() { ItemRef.clearCache(); loadConfigs(); + if (ItemScanService.get() != null) { + // Food skipped as unchanged must be looked at again with the new config. + ItemScanService.get().rescanAll(); + } HusbandryLifecycleListener.applyStatsRevision(); craftingManager.rebuildStations(); NutritionDrainTask.stop(); diff --git a/src/main/java/net/tfminecraft/cooking/fishing/LegacyFishScan.java b/src/main/java/net/tfminecraft/cooking/fishing/LegacyFishScan.java index 0eef5b8..04ebba4 100644 --- a/src/main/java/net/tfminecraft/cooking/fishing/LegacyFishScan.java +++ b/src/main/java/net/tfminecraft/cooking/fishing/LegacyFishScan.java @@ -26,6 +26,16 @@ public boolean matches(ItemStack stack) { return isFood(data) ? SeafoodWholeItems.carriesCatchData(data) : stack.getType() == Material.COD; } + /** A slot that doesn't match never will until its item changes; a matching one is checked every scan, as before. */ + @Override + public long scan(Player player, Inventory inventory, int slot, ItemStack stack) { + if (!matches(stack)) { + return WHEN_CHANGED; + } + update(player, inventory, slot, stack); + return EVERY_SCAN; + } + @Override public void update(Player player, Inventory inventory, int slot, ItemStack stack) { if (inventory == null || slot < 0) { diff --git a/src/main/java/net/tfminecraft/cooking/item/FoodItem.java b/src/main/java/net/tfminecraft/cooking/item/FoodItem.java index 34aefeb..81a0258 100644 --- a/src/main/java/net/tfminecraft/cooking/item/FoodItem.java +++ b/src/main/java/net/tfminecraft/cooking/item/FoodItem.java @@ -733,8 +733,12 @@ private static double roundToTenth(double value) { // -------------------------------------------------------------- public static FoodItem fromItem(ItemStack stack) { if (stack == null || !stack.hasItemMeta()) return null; + return fromItem(stack, stack.getItemMeta()); + } - ItemMeta meta = stack.getItemMeta(); + /** As {@link #fromItem(ItemStack)}, reading the stack's meta the caller already has, so it isn't copied again. */ + public static FoodItem fromItem(ItemStack stack, ItemMeta meta) { + if (stack == null || meta == null) return null; var pdc = meta.getPersistentDataContainer(); String id = pdc.get(Keys.FOOD_ID, PersistentDataType.STRING); diff --git a/src/main/java/net/tfminecraft/cooking/manager/TagManager.java b/src/main/java/net/tfminecraft/cooking/manager/TagManager.java index 2646104..bd25608 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/TagManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/TagManager.java @@ -9,7 +9,7 @@ import org.bukkit.persistence.PersistentDataType; import net.tfminecraft.cooking.item.FoodItem; -import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.FoodScan; import net.tfminecraft.cooking.utils.Keys; import net.tfminecraft.tlibs.itemscan.ItemScanHandler; @@ -17,7 +17,7 @@ public class TagManager implements ItemScanHandler { private static final int OFF_HAND_SLOT = 40; - // Runs on every scanned slot: read the key through the stack's data view instead of copying its whole meta. + // Runs on every new or changed slot: read the key through the stack's data view instead of copying its whole meta. @Override public boolean matches(ItemStack stack) { if (stack == null || stack.getType() == Material.AIR) { @@ -26,27 +26,33 @@ public boolean matches(ItemStack stack) { return stack.getPersistentDataContainer().has(Keys.FOOD_ID, PersistentDataType.STRING); } - // Keep the existing legacy text representation, formatting, and exact-string comparisons. - @SuppressWarnings("deprecation") @Override public void update(Player player, Inventory inventory, int slot, ItemStack stack) { - FoodItem food = FoodItem.fromItem(stack); - if (food == null) { - return; + scan(player, inventory, slot, stack); + } + + /** + * Ages a food and rewrites its slot only when it would look different (FoodScan), reading its meta once. Answers + * when TLibs must show it the unchanged item again: when it can next look different, or every scan while it is + * held, since held food waits for a visible change. + */ + @Override + public long scan(Player player, Inventory inventory, int slot, ItemStack stack) { + if (!matches(stack)) { + return WHEN_CHANGED; } - if (!food.shouldUpdate()) { - ItemMeta meta = stack.getItemMeta(); - if (meta != null && !ItemUpdater.needsLoreRebuild( - meta.getLore(), - meta.getPersistentDataContainer().has(Keys.LORE_INDEX_MAP, PersistentDataType.STRING))) { - return; - } + ItemMeta meta = stack.getItemMeta(); + FoodItem food = FoodItem.fromItem(stack, meta); + if (food == null) { + // An unknown food id: only a reload can change that, and Cooking's reload rescans every slot. + return WHEN_CHANGED; } - boolean held = isHeldSlot(player, inventory, slot); - ItemStack updated = ItemUpdater.updateItem(stack, food, null, held); + FoodScan.Result result = FoodScan.update(stack, meta, food, isHeldSlot(player, inventory, slot)); + ItemStack updated = result.item(); if (updated != null && !updated.getType().isAir() && inventory != null && slot >= 0) { inventory.setItem(slot, updated); } + return result.recheckAt(); } public static boolean isHeldSlot(Player player, Inventory inventory, int slot) { diff --git a/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java b/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java new file mode 100644 index 0000000..68c5351 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java @@ -0,0 +1,144 @@ +package net.tfminecraft.cooking.utils; + +import java.util.List; + +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.tlibs.itemscan.ItemScanHandler; + +/** + * The inventory scan's update of a food (TagManager). A food ages by the wall clock from its stored clock, value and + * 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. + */ +public final class FoodScan { + + // Further than this the next step never comes: a food aging at a huge multiplier, or a track with no next step. + private static final double MAX_SECONDS = 1e12; + + private FoodScan() {} + + /** + * What the scan puts in the slot (null leaves it as it is) and the {@link ItemScanHandler#scan} answer: when the + * unchanged food needs the scan again. + */ + 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. + */ + // 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) { + 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); + } + 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); + } + long now = StackNormalizer.quantizedNow(); + Long stored = pdc.get(Keys.LAST_UPDATE, PersistentDataType.LONG); + long deltaSeconds = stored == null ? 0 : (now - stored) / 1000; + boolean expired = WarmthUtils.expireIfRoomTemp(fi); + boolean changed = deltaSeconds > 0 && ItemUpdater.applyAging(fi, deltaSeconds); + 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); + } + if (held) { + return new Result(null, recheckAt); + } + ItemStack rebuilt = ItemUpdater.applyItemUpdate(stack.clone(), fi, null); + return new Result(looksTheSame(stack, meta, rebuilt) ? null : rebuilt, recheckAt); + } + + /** + * True when the clock must be written: missing, ahead of now, or not on a whole second (as a newly built food's + * is). The scan used to set it to the second on every pass; once it is, leaving it gives the same aging. + */ + static boolean clockNeedsWriting(Long stored, long now) { + return stored == null || stored > now || Math.floorMod(stored, 1000L) != 0; + } + + /** + * The time from which this food, aged to now and left unchanged, can show its next tag step or lose its warmth. + * It is a second early, so rounding never makes the scan late; a check that comes early just finds nothing. + */ + static long nextChange(FoodItem fi, long now) { + double seconds = Double.POSITIVE_INFINITY; + for (TagTrack track : fi.getTagTracks()) { + if (!track.isAgeable()) continue; + long target = nextStepValue(track); + if (target == Long.MAX_VALUE) continue; + double units = target - track.getValue() - fi.getAgeRemainder(track.getId()); + seconds = Math.min(seconds, units * fi.resolveAgeMultiplier(track.getId())); + } + if (!(seconds <= MAX_SECONDS)) { + return ItemScanHandler.WHEN_CHANGED; + } + long whole = Math.max(0L, (long) Math.ceil(seconds) - 1L); + return now + whole * 1000L; + } + + /** + * The track value at which its shown step next changes, or Long.MAX_VALUE if it never does: the first step it + * hasn't reached (TagTrack.getCurrentStep), and room temperature for warmth. Other tracks stop aging once they + * reach their last step (TagTrack.setValue). + */ + static long nextStepValue(TagTrack track) { + boolean warmth = "warmth".equals(track.getId()); + long next = warmth ? WarmthUtils.ROOM_TEMP_AGE : Long.MAX_VALUE; + List steps = track.getSteps(); + int value = track.getValue(); + if (steps.isEmpty() || (!warmth && value >= steps.get(steps.size() - 1).getRequiredValue())) { + return next; + } + for (TagStep step : steps) { + if (value < step.getRequiredValue()) { + return Math.min(next, step.getRequiredValue()); + } + } + return next; + } + + /** True when rebuilt differs from the current stack only in its aging clock, values and remainder. */ + static boolean looksTheSame(ItemStack current, ItemMeta currentMeta, ItemStack rebuilt) { + ItemStack probe = rebuilt.clone(); + ItemMeta probeMeta = probe.getItemMeta(); + if (probeMeta == null) { + return false; + } + PersistentDataContainer from = currentMeta.getPersistentDataContainer(); + PersistentDataContainer to = probeMeta.getPersistentDataContainer(); + copy(from, to, Keys.LAST_UPDATE, PersistentDataType.LONG); + copy(from, to, Keys.AGE_REMAINDER, PersistentDataType.STRING); + copy(from, to, Keys.TAGS, PersistentDataType.STRING); + probe.setItemMeta(probeMeta); + return current.isSimilar(probe); + } + + private static void copy(PersistentDataContainer from, PersistentDataContainer to, NamespacedKey key, + PersistentDataType type) { + T value = from.get(key, type); + if (value == null) { + to.remove(key); + } else { + to.set(key, type, value); + } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java index ede4fa6..2a48aa7 100644 --- a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java +++ b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java @@ -260,9 +260,14 @@ class CookingLifecycleTest { @Test void reloadRefreshesConfigStationsAndPeriodicNutritionAndHusbandryTasks() { Cooking plugin = loadPlugin(); server.getScheduler().performOneTick(); + ItemScanService scan = mock(ItemScanService.class); + scans.when(ItemScanService::get).thenReturn(scan); plugin.reloadAll(); + // Food the scan skipped as unchanged is looked at again with the new config. + verify(scan).rescanAll(); + verify(built(ConfigLoader.class), times(2)).loadConfig(new File(plugin.getDataFolder(), "config.yml")); verify(built(CraftingManager.class)).rebuildStations(); husbandry.verify(HusbandryLifecycleListener::applyStatsRevision, times(2)); diff --git a/src/test/java/net/tfminecraft/cooking/fishing/LegacyFishScanSkipTest.java b/src/test/java/net/tfminecraft/cooking/fishing/LegacyFishScanSkipTest.java new file mode 100644 index 0000000..9c14e2d --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/fishing/LegacyFishScanSkipTest.java @@ -0,0 +1,32 @@ +package net.tfminecraft.cooking.fishing; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.tlibs.itemscan.ItemScanHandler; + +class LegacyFishScanSkipTest { + + @Test + void onlyMatchingFishAreCheckedOnEveryScan() { + LegacyFishScan scan = spy(new LegacyFishScan()); + Player player = mock(Player.class); + Inventory inventory = mock(Inventory.class); + ItemStack stack = mock(ItemStack.class); + doNothing().when(scan).update(any(), any(), anyInt(), any()); + + doReturn(false).when(scan).matches(stack); + assertEquals(ItemScanHandler.WHEN_CHANGED, scan.scan(player, inventory, 3, stack)); + verify(scan, never()).update(any(), any(), anyInt(), any()); + + doReturn(true).when(scan).matches(stack); + assertEquals(ItemScanHandler.EVERY_SCAN, scan.scan(player, inventory, 3, stack)); + verify(scan).update(player, inventory, 3, stack); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/manager/CookingAndTagManagersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/CookingAndTagManagersCoverageTest.java index f7cd35e..4fa0833 100644 --- a/src/test/java/net/tfminecraft/cooking/manager/CookingAndTagManagersCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/manager/CookingAndTagManagersCoverageTest.java @@ -12,7 +12,6 @@ import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; -import org.bukkit.configuration.MemoryConfiguration; import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.world.ChunkLoadEvent; @@ -228,34 +227,4 @@ void scanMatchingRequiresFoodMetadataAndRecognizesTheOwningPlayersHeldSlots() { assertFalse(TagManager.isHeldSlot(env.player, null, 3)); assertFalse(TagManager.isHeldSlot(env.player, env.server.createInventory(null, 9), 3)); assertFalse(TagManager.isHeldSlot(env.player, env.server.addPlayer().getInventory(), 3)); } - - @Test - void unchangedFoodAndNonFoodAreNotRewrittenButMissingLoreIsRebuilt() { - Inventory inventory = env.server.createInventory(null, 9); ItemStack item = env.stack(kitchen.food("steak", "meat"), Material.BEEF, 2); - inventory.setItem(0, item); tags.update(env.player, inventory, 0, new ItemStack(Material.STONE)); tags.update(env.player, inventory, 0, item); - kitchen.updater.verify(() -> ItemUpdater.updateItem(any(), any(), any(), anyBoolean()), never()); assertEquals(item, inventory.getItem(0)); - kitchen.updater.when(() -> ItemUpdater.needsLoreRebuild(any(), anyBoolean())).thenReturn(true); - tags.update(env.player, inventory, 0, item); kitchen.updater.verify(() -> ItemUpdater.updateItem(item, env.resolve(item), null, false)); - assertEquals(2, inventory.getItem(0).getAmount()); assertNotEquals(item, inventory.getItem(0)); - } - - @Test - void changingFoodUsesHeldContextOnlyForThePlayersSelectedOrOffhandSlot() { - MemoryConfiguration config = new MemoryConfiguration(); config.set("name", "Steak"); config.set("update", true); - FoodItem food = new FoodItem("steak", config); ItemStack stack = env.stack(food, Material.BEEF, 2); var inventory = env.player.getInventory(); - inventory.setItem(0, stack); tags.update(env.player, inventory, 0, stack); - kitchen.updater.verify(() -> ItemUpdater.updateItem(stack, food, null, true)); assertEquals(2, inventory.getItem(0).getAmount()); - tags.update(env.player, inventory, 1, stack); kitchen.updater.verify(() -> ItemUpdater.updateItem(stack, food, null, false)); - tags.update(env.player, inventory, 40, stack); assertEquals(2, inventory.getItemInOffHand().getAmount()); - tags.update(env.player, null, 0, stack); tags.update(env.player, inventory, -1, stack); - } - - @ParameterizedTest - @ValueSource(strings = {"null", "air"}) - void failedTagUpdatesNeverDeleteTheExistingFood(String failure) { - ItemStack source = env.stack(kitchen.food("steak", "meat"), Material.BEEF, 2); Inventory inventory = env.server.createInventory(null, 9); inventory.setItem(0, source); - kitchen.updater.when(() -> ItemUpdater.needsLoreRebuild(any(), anyBoolean())).thenReturn(true); - kitchen.updater.when(() -> ItemUpdater.updateItem(any(), any(), any(), anyBoolean())).thenReturn(failure.equals("null") ? null : new ItemStack(Material.AIR)); - tags.update(env.player, inventory, 0, source); assertEquals(source, inventory.getItem(0)); - } } diff --git a/src/test/java/net/tfminecraft/cooking/manager/TagManagerScanTest.java b/src/test/java/net/tfminecraft/cooking/manager/TagManagerScanTest.java new file mode 100644 index 0000000..8c6657d --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/manager/TagManagerScanTest.java @@ -0,0 +1,115 @@ +package net.tfminecraft.cooking.manager; + +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.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +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 org.mockito.MockedStatic; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.utils.FoodScan; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.Keys; +import net.tfminecraft.tlibs.itemscan.ItemScanHandler; + +class TagManagerScanTest { + Cooking plugin; + List foods; + ServerMock server; + PlayerMock player; + TagManager tags = new TagManager(); + MockedStatic scans; + + @BeforeEach + void setup() { + server = 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(); + MemoryConfiguration config = new MemoryConfiguration(); + config.set("name", "Steak"); + FoodLoader.oList.add(new FoodItem("steak", config)); + player = server.addPlayer(); + scans = mockStatic(FoodScan.class); + } + + @AfterEach + void cleanup() { + scans.close(); + FoodLoader.oList.clear(); + FoodLoader.oList.addAll(foods); + MockBukkit.unmock(); + Cooking.plugin = plugin; + } + + @Test + void nonFoodAndUnknownFoodWaitUntilTheItemChanges() { + assertEquals(ItemScanHandler.WHEN_CHANGED, tags.scan(player, null, 0, new ItemStack(Material.STONE))); + ItemStack unknown = new ItemStack(Material.BEEF); + var meta = unknown.getItemMeta(); + meta.getPersistentDataContainer().set(Keys.FOOD_ID, PersistentDataType.STRING, "missing"); + unknown.setItemMeta(meta); + assertEquals(ItemScanHandler.WHEN_CHANGED, tags.scan(player, null, 0, unknown)); + scans.verifyNoInteractions(); + } + + @Test + void theSlotGetsWhatFoodScanBuildsWithHeldContextForTheSelectedOrOffhandSlot() { + ItemStack steak = steak(); + ItemStack rebuilt = new ItemStack(Material.COOKED_BEEF, 2); + scans.when(() -> FoodScan.update(any(), any(), any(), anyBoolean())).thenReturn(new FoodScan.Result(rebuilt, 123L)); + PlayerInventory inventory = player.getInventory(); + inventory.setHeldItemSlot(0); + inventory.setItem(0, steak); + assertEquals(123L, tags.scan(player, inventory, 0, steak)); + scans.verify(() -> FoodScan.update(eq(steak), any(), argThat(food -> food.getId().equals("steak")), eq(true))); + assertEquals(Material.COOKED_BEEF, inventory.getItem(0).getType()); + + tags.update(player, inventory, 1, steak); + scans.verify(() -> FoodScan.update(eq(steak), any(), any(), eq(false))); + assertEquals(Material.COOKED_BEEF, inventory.getItem(1).getType()); + + // An item being picked up, or no slot, is never written anywhere. + Inventory chest = server.createInventory(null, 9); + assertEquals(123L, tags.scan(player, null, 0, steak)); + assertEquals(123L, tags.scan(player, chest, -1, steak)); + assertTrue(chest.isEmpty()); + } + + @Test + void nothingToWriteOrAFailedRebuildLeavesTheFoodInItsSlot() { + ItemStack steak = steak(); + Inventory chest = server.createInventory(null, 9); + chest.setItem(0, steak); + scans.when(() -> FoodScan.update(any(), any(), any(), anyBoolean())).thenReturn(new FoodScan.Result(null, 5L)); + assertEquals(5L, tags.scan(player, chest, 0, steak)); + scans.when(() -> FoodScan.update(any(), any(), any(), anyBoolean())) + .thenReturn(new FoodScan.Result(new ItemStack(Material.AIR), ItemScanHandler.EVERY_SCAN)); + assertEquals(ItemScanHandler.EVERY_SCAN, tags.scan(player, chest, 0, steak)); + assertEquals(steak, chest.getItem(0)); + } + + private ItemStack steak() { + return ItemBuilder.stamp(new ItemStack(Material.BEEF, 2), new FoodItem(FoodLoader.getByString("steak")), "Steak"); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/utils/FoodScanTest.java b/src/test/java/net/tfminecraft/cooking/utils/FoodScanTest.java new file mode 100644 index 0000000..b1c2336 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/utils/FoodScanTest.java @@ -0,0 +1,278 @@ +package net.tfminecraft.cooking.utils; + +import static org.junit.jupiter.api.Assertions.*; +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.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.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.tlibs.itemscan.ItemScanHandler; + +class FoodScanTest { + Cooking plugin; + List foods; + List tracks; + MockedStatic clock; + 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); + } + + @AfterEach + void cleanup() { + clock.close(); + FoodLoader.oList.clear(); + FoodLoader.oList.addAll(foods); + TrackLoader.oList.clear(); + TrackLoader.oList.addAll(tracks); + MockBukkit.unmock(); + Cooking.plugin = plugin; + } + + /** + * The old scan rewrote unheld food on every pass. The new one leaves it until it would look different, and its + * food must look exactly the same as the old one's after every scan, through steps, warmth expiry and odd gaps. + * The multipliers are powers of two so both add up seconds exactly: with others, the old per-scan sums can round + * a step that falls exactly on a scan to the next scan, while one sum over the whole time does not. + */ + @Test + void skippedRewritesLookExactlyLikeRewritingOnEveryScan() { + for (long start : new long[] {10_000L, 10_300L}) { + ItemStack old = warmBread(start); + ItemStack scanned = old.clone(); + int[] gaps = {1, 3, 4, 2, 7, 1, 5}; + int writes = 0; + int visibleChanges = 0; + long recheckAt = Long.MIN_VALUE; + now = 10_000L; + for (int scan = 0; scan < 40; scan++) { + now += gaps[scan % gaps.length] * 1000L; + List loreBefore = lore(old); + ItemStack rewritten = ItemUpdater.updateItem(old, FoodItem.fromItem(old), null, false); + if (rewritten != null) old = rewritten; + boolean looksDifferent = !lore(old).equals(loreBefore); + if (looksDifferent) visibleChanges++; + // The new scan may skip the slot until recheckAt; it must never skip a visible change. + if (looksDifferent) assertTrue(recheckAt <= now, "skipped a change at " + now); + if (now < recheckAt) { + assertEquals(lore(old), lore(scanned), "at " + now); + continue; + } + ItemMeta meta = scanned.getItemMeta(); + FoodScan.Result result = FoodScan.update(scanned, meta, FoodItem.fromItem(scanned, meta), false); + recheckAt = result.recheckAt(); + if (result.item() != null) { + scanned = result.item(); + writes++; + } + assertEquals(lore(old), lore(scanned), "at " + now); + assertEquals(steps(old), steps(scanned), "at " + now); + } + assertTrue(visibleChanges >= 3, "the food should go aging, warm, stale and lose its warmth"); + // One write per visible change, plus setting the clock to the second once when it started off it. + assertEquals(visibleChanges + (start % 1000 == 0 ? 0 : 1), writes); + assertEquals(ItemScanHandler.WHEN_CHANGED, recheckAt); + } + } + + @Test + void heldFoodKeepsTheOldHeldRuleAndIsCheckedEveryScan() { + ItemStack bread = warmBread(10_000L); + now = 11_000L; + FoodScan.Result quiet = scan(bread, true); + assertNull(quiet.item()); + assertEquals(ItemScanHandler.EVERY_SCAN, quiet.recheckAt()); + now = 20_000L; + FoodScan.Result changed = scan(bread, true); + assertNotNull(changed.item()); + assertEquals(ItemScanHandler.EVERY_SCAN, changed.recheckAt()); + + // A held food whose clock is missing waits like before; an unheld one gets its clock. + ItemStack noClock = withoutClock(bread); + assertNull(scan(noClock, true).item()); + ItemStack clocked = scan(noClock, false).item(); + assertEquals(20_000L, clocked.getItemMeta().getPersistentDataContainer().get(Keys.LAST_UPDATE, PersistentDataType.LONG)); + } + + @Test + void theClockIsWrittenWhenMissingAheadOrOffTheSecond() { + assertTrue(FoodScan.clockNeedsWriting(null, 5_000L)); + assertTrue(FoodScan.clockNeedsWriting(6_000L, 5_000L)); + assertTrue(FoodScan.clockNeedsWriting(4_300L, 5_000L)); + assertFalse(FoodScan.clockNeedsWriting(5_000L, 5_000L)); + assertFalse(FoodScan.clockNeedsWriting(2_000L, 5_000L)); + ItemStack ahead = warmBread(90_000L); + now = 30_000L; + assertNotNull(scan(ahead, false).item()); + } + + @Test + void foodThatLooksDifferentAfterAReloadIsRebuiltWithoutANewStep() { + ItemStack bread = warmBread(10_000L); + now = 11_000L; + assertNull(scan(bread, false).item()); + // A reload renames the fresh step: same step, new lore. + TrackLoader.oList.set(0, new TagTrack("freshness", true, List.of( + new TagStep("fresh", "Just baked", 0, 1, 1), + new TagStep("aging", "Aging", 4, .9, .9), + new TagStep("stale", "Stale", 9, .7, .7)))); + ItemStack rebuilt = scan(bread, false).item(); + assertNotNull(rebuilt); + assertTrue(String.join("|", lore(rebuilt)).contains("Just baked")); + assertNull(scan(rebuilt, false).item()); + } + + @Test + void foodsThatNeverAgeAreOnlyRebuiltForBrokenLore() { + 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"); + FoodScan.Result fine = scan(stack, false); + assertNull(fine.item()); + assertEquals(ItemScanHandler.WHEN_CHANGED, fine.recheckAt()); + ItemMeta meta = stack.getItemMeta(); + meta.setLore(List.of("Salt")); + stack.setItemMeta(meta); + assertNotNull(scan(stack, false).item()); + } + + @Test + void sauceFoodsAreRewrittenOnEveryScanAsBefore() { + FoodItem plate = food("plate", new MemoryConfiguration()); + FoodItem sauce = food("gravy", new MemoryConfiguration()); + FoodItem dish = new FoodItem(plate); + FoodItem gravy = new FoodItem(sauce); + 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; + FoodScan.Result result = scan(stack, false); + assertNotNull(result.item()); + assertEquals(ItemScanHandler.EVERY_SCAN, result.recheckAt()); + } + + @Test + void nextChangeFindsTheEarliestStepOrWarmthLoss() { + FoodItem bread = new FoodItem(food("bread", new MemoryConfiguration())); + assertEquals(ItemScanHandler.WHEN_CHANGED, FoodScan.nextChange(bread, 1_000L)); + bread.addOrModifyTrack(new TagTrack("cooked", false, List.of(new TagStep("raw", "Raw", 0, 1, 1)))); + bread.addOrModifyTrack(new TagTrack("plain", true, List.of())); + assertEquals(ItemScanHandler.WHEN_CHANGED, FoodScan.nextChange(bread, 1_000L)); + bread.addOrModifyTrack(TrackLoader.getByString("freshness")); + bread.getTagTrack("freshness").forceSetValue(9); + assertEquals(ItemScanHandler.WHEN_CHANGED, FoodScan.nextChange(bread, 1_000L), "stale food stops aging"); + bread.getTagTrack("freshness").forceSetValue(1); + bread.setAgeRemainder("freshness", .5); + // 2.5 units to aging at 1 s each: due from 2 s on, a second early. + assertEquals(3_000L, FoodScan.nextChange(bread, 1_000L)); + bread.setAgeRemainder("freshness", 7); + assertEquals(1_000L, FoodScan.nextChange(bread, 1_000L), "already due"); + + TagTrack warmth = new TagTrack("warmth", true, List.of(new TagStep("hot", "Hot", 0, 1, 1))); + warmth.forceSetValue(15); + assertEquals(20L, FoodScan.nextStepValue(warmth), "past every step, warmth still runs out at room temperature"); + assertEquals(20L, FoodScan.nextStepValue(new TagTrack("warmth", true, List.of()))); + assertEquals(3L, FoodScan.nextStepValue(TrackLoader.getByString("warmth"))); + assertEquals(Long.MAX_VALUE, FoodScan.nextStepValue(new TagTrack("plain", true, List.of()))); + + MemoryConfiguration slow = new MemoryConfiguration(); + slow.set("age.freshness", 1e15); + FoodItem honey = new FoodItem(food("honey", slow)); + honey.addOrModifyTrack(TrackLoader.getByString("freshness")); + assertEquals(ItemScanHandler.WHEN_CHANGED, FoodScan.nextChange(honey, 1_000L)); + } + + @Test + void itemsWithoutMetaNeverLookTheSame() { + ItemStack bread = warmBread(10_000L); + assertFalse(FoodScan.looksTheSame(bread, bread.getItemMeta(), new ItemStack(Material.AIR))); + } + + private FoodScan.Result scan(ItemStack stack, boolean held) { + ItemMeta meta = stack.getItemMeta(); + return FoodScan.update(stack, meta, FoodItem.fromItem(stack, meta), held); + } + + /** Fresh, hot bread whose clock reads start. Freshness ages at 2 s a step, warmth at 4 s. */ + private ItemStack warmBread(long start) { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("age.freshness", 2.0); + config.set("age.warmth", 4.0); + FoodItem bread = new FoodItem(food("bread", config)); + bread.addOrModifyTrack(TrackLoader.getByString("freshness")); + bread.addOrModifyTrack(TrackLoader.getByString("warmth")); + bread.setLastUpdate(start); + // Built food always has a model; without one, the first rewrite would add it. + bread.setModel(new FoodModel(new ItemStack(Material.BREAD))); + return ItemBuilder.stamp(new ItemStack(Material.BREAD, 3), bread, "Bread"); + } + + private static ItemStack withoutClock(ItemStack stack) { + ItemStack copy = stack.clone(); + ItemMeta meta = copy.getItemMeta(); + meta.getPersistentDataContainer().remove(Keys.LAST_UPDATE); + copy.setItemMeta(meta); + return copy; + } + + @SuppressWarnings("deprecation") + private static List lore(ItemStack stack) { + return stack.getItemMeta().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; + } +}