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; + } +}