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