diff --git a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java
index e2b987e..39f9d65 100644
--- a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java
+++ b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java
@@ -12,7 +12,7 @@
import net.tfminecraft.cooking.Cooking;
import net.tfminecraft.cooking.heat.HeatSources;
import net.tfminecraft.cooking.item.FoodItem;
-import net.tfminecraft.cooking.utils.ItemUpdater;
+import net.tfminecraft.cooking.utils.SlotAging;
import net.tfminecraft.interactiblefurniture.furniture.Furniture;
import net.tfminecraft.interactiblefurniture.furniture.PlacedFurnitureSlot;
import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot;
@@ -81,7 +81,7 @@ private void ageTray(Furniture tray, BakingTrayRecipe recipe) {
private boolean ageSlot(PlacedSlot slot, Furniture tray) {
ItemStack item = slot.getCurrentItem();
- if (item == null || item.getType().isAir()) {
+ if (item == null || item.getType().isAir() || SlotAging.isResting(slot, item)) {
return false;
}
@@ -90,7 +90,7 @@ private boolean ageSlot(PlacedSlot slot, Furniture tray) {
return false;
}
- ItemStack updated = ItemUpdater.updateItem(item, foodItem, tray.getId());
+ ItemStack updated = SlotAging.age(slot, item, foodItem, tray.getId());
if (updated == null || updated.getType().isAir()) {
return false;
}
diff --git a/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java b/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java
index 97c9579..555a78c 100644
--- a/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java
+++ b/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java
@@ -78,6 +78,10 @@ public static boolean tryCarve(Player player, Furniture furniture, PlacedSlot sl
if (roast == null) return false;
+ // The hook only rewrites a roast when it looks different, so bring its aging up to now before the cuts and
+ // the roast left on the hook (which starts a new clock) take its numbers.
+ roast.updateAge();
+
CarvableRoastUtils.readCarveState(roast, stack);
if (!CarvableRoastUtils.isCarvable(roast)) return false;
diff --git a/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java b/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java
index f91ecc7..4c8dade 100644
--- a/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java
+++ b/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java
@@ -17,7 +17,7 @@
import net.tfminecraft.cooking.carve.CarvableRoastUtils;
import net.tfminecraft.cooking.carve.CarveHandler;
import net.tfminecraft.cooking.item.FoodItem;
-import net.tfminecraft.cooking.utils.ItemUpdater;
+import net.tfminecraft.cooking.utils.SlotAging;
import net.tfminecraft.cooking.utils.StationChunks;
import net.tfminecraft.interactiblefurniture.events.FurniturePunchEvent;
import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemAddEvent;
@@ -107,14 +107,15 @@ private static void tickHook(Furniture furniture) {
return;
}
ItemStack stack = slot.getCurrentItem();
- if (stack == null) {
+ // An unchanged roast that can't look different yet keeps its item and display as they are.
+ if (stack == null || SlotAging.isResting(slot, stack)) {
return;
}
FoodItem foodItem = FoodItem.fromItem(stack);
if (foodItem == null) {
return;
}
- ItemStack updated = ItemUpdater.updateItem(stack, foodItem, furniture.getId());
+ ItemStack updated = SlotAging.age(slot, stack, foodItem, furniture.getId());
if (updated == null || updated.getType().isAir()) {
return;
}
diff --git a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java
index b1da9a6..b97b03b 100644
--- a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java
+++ b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java
@@ -104,6 +104,8 @@ private void tickTray(Furniture consumer, Furniture tray, BakingTrayRecipe recip
}
BakingTrayState.incrementSlotElapsed(tray, slotId);
+ // Save the bake time with the next autosave: the tray's aging timer no longer rewrites it every second.
+ changed = true;
int elapsed = BakingTrayState.getSlotElapsed(tray, slotId);
int cookedValue = cooked == null ? RAW : cooked.getValue();
diff --git a/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java b/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java
index 2818dd4..e4273e7 100644
--- a/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java
+++ b/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java
@@ -10,7 +10,7 @@
import net.tfminecraft.cooking.Cooking;
import net.tfminecraft.cooking.cache.FurnitureCache;
import net.tfminecraft.cooking.item.FoodItem;
-import net.tfminecraft.cooking.utils.ItemUpdater;
+import net.tfminecraft.cooking.utils.SlotAging;
import net.tfminecraft.interactiblefurniture.furniture.Furniture;
import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot;
@@ -59,7 +59,8 @@ private void ageTrough(Furniture furniture) {
private boolean ageSlot(Furniture furniture, PlacedSlot slot, String slotId) {
ItemStack item = slot.getCurrentItem();
- if (item == null || item.getType().isAir()) {
+ // A resting food was neither rotten nor unknown when last aged, and it can't have gone off since.
+ if (item == null || item.getType().isAir() || SlotAging.isResting(slot, item)) {
return false;
}
@@ -69,7 +70,7 @@ private boolean ageSlot(Furniture furniture, PlacedSlot slot, String slotId) {
return true;
}
- ItemStack updated = ItemUpdater.updateItem(item, foodItem, furniture.getId());
+ ItemStack updated = SlotAging.age(slot, item, foodItem, furniture.getId());
if (updated != null && updated.getType().isAir()) {
updated = null;
}
diff --git a/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java b/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java
index 68c5351..f53b9f7 100644
--- a/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java
+++ b/src/main/java/net/tfminecraft/cooking/utils/FoodScan.java
@@ -18,7 +18,8 @@
* remainder, so writing the aged numbers back changes nothing a player sees and the food ages the same either way.
* The scan therefore only rewrites a food that would look different (a new tag step, warmth gone, lore or model built
* differently, as after a reload or an update), whose clock must be set, or that has a sauce (below). It also says
- * when the unchanged food could next look different, so TLibs can skip the slot until then.
+ * when the unchanged food could next look different, so TLibs can skip the slot until then. SlotAging does the same
+ * for food lying in furniture.
*/
public final class FoodScan {
@@ -37,18 +38,26 @@ public record Result(ItemStack item, long recheckAt) {}
* Ages the food (parsed from stack, whose meta is meta) to now. A held food is only rewritten when today's held
* rule says so (a new step, warmth gone or broken lore), so eating it isn't interrupted.
*/
+ public static Result update(ItemStack stack, ItemMeta meta, FoodItem fi, boolean held) {
+ return update(stack, meta, fi, held, null);
+ }
+
+ /**
+ * As {@link #update(ItemStack, ItemMeta, FoodItem, boolean)} for food in a slot of the furniture whose id is
+ * furniture (null in an inventory), which picks the model the food is built with there.
+ */
// Keep the existing legacy text representation, formatting, and exact-string comparisons.
@SuppressWarnings("deprecation")
- public static Result update(ItemStack stack, ItemMeta meta, FoodItem fi, boolean held) {
+ public static Result update(ItemStack stack, ItemMeta meta, FoodItem fi, boolean held, String furniture) {
PersistentDataContainer pdc = meta.getPersistentDataContainer();
boolean broken = ItemUpdater.needsLoreRebuild(meta.getLore(), pdc.has(Keys.LORE_INDEX_MAP, PersistentDataType.STRING));
if (!fi.shouldUpdate()) {
- return new Result(broken ? ItemUpdater.applyItemUpdate(stack, fi, null) : null, ItemScanHandler.WHEN_CHANGED);
+ return new Result(broken ? ItemUpdater.applyItemUpdate(stack, fi, furniture) : null, ItemScanHandler.WHEN_CHANGED);
}
if (fi.hasSauce()) {
// A sauce keeps no clock or remainder of its own, so it only ages by whole steps each time the food is
// rewritten. Rewrite it on every scan as before, so sauces age exactly as they always have.
- return new Result(ItemUpdater.updateItem(stack, fi, null, held), ItemScanHandler.EVERY_SCAN);
+ return new Result(ItemUpdater.updateItem(stack, fi, furniture, held), ItemScanHandler.EVERY_SCAN);
}
long now = StackNormalizer.quantizedNow();
Long stored = pdc.get(Keys.LAST_UPDATE, PersistentDataType.LONG);
@@ -58,12 +67,12 @@ public static Result update(ItemStack stack, ItemMeta meta, FoodItem fi, boolean
fi.setLastUpdate(now);
long recheckAt = held ? ItemScanHandler.EVERY_SCAN : nextChange(fi, now);
if (changed || expired || broken || (!held && clockNeedsWriting(stored, now))) {
- return new Result(ItemUpdater.applyItemUpdate(stack, fi, null), recheckAt);
+ return new Result(ItemUpdater.applyItemUpdate(stack, fi, furniture), recheckAt);
}
if (held) {
return new Result(null, recheckAt);
}
- ItemStack rebuilt = ItemUpdater.applyItemUpdate(stack.clone(), fi, null);
+ ItemStack rebuilt = ItemUpdater.applyItemUpdate(stack.clone(), fi, furniture);
return new Result(looksTheSame(stack, meta, rebuilt) ? null : rebuilt, recheckAt);
}
diff --git a/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java b/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java
index 52db17d..7299588 100644
--- a/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java
+++ b/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java
@@ -51,9 +51,13 @@ public static ItemStack apply(String ref, ItemStack source) {
return out;
}
- /** Drops every kept template; the next use of each ref builds it again. */
+ /**
+ * Drops every kept template; the next use of each ref builds it again. Runs on /cooking reload and /iareload, so
+ * food resting in furniture slots is looked at again too: it may now be built differently.
+ */
public static void clearCache() {
templates = null;
+ SlotAging.forgetAll();
}
/** Turns template keeping on or off; either way the kept templates are dropped. */
diff --git a/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java b/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java
new file mode 100644
index 0000000..e6478e3
--- /dev/null
+++ b/src/main/java/net/tfminecraft/cooking/utils/SlotAging.java
@@ -0,0 +1,73 @@
+package net.tfminecraft.cooking.utils;
+
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+
+import net.tfminecraft.cooking.item.FoodItem;
+import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot;
+import net.tfminecraft.tlibs.itemscan.ItemScanHandler;
+
+/**
+ * Ages the food lying in a furniture slot (a meat hook, a baking tray, a trough) on its station's one-second timer,
+ * the way the inventory scan ages unheld food (FoodScan). The timers used to rebuild and rewrite every such food each
+ * second only to store its new clock and aged numbers; the food ages the same from its stored clock, values and
+ * remainder, so the slot is now only written when the food would look different.
+ *
+ *
Like TLibs' scan, it keeps a copy of each slot's food that it left as it was, and when that food can next look
+ * different. While the slot holds an equal item and that time hasn't come, the timer skips the slot. Any change to the
+ * item (taken, replaced, carved, glazed, changed in place) ends the skip, as does {@link #forgetAll()}.
+ */
+public final class SlotAging {
+
+ /** A slot's food as the timer last left it, when it can next look different, and the generation it rests in. */
+ private record Resting(ItemStack item, long until, int generation) {}
+
+ // By slot identity; a slot dropped by its furniture drops out on its own. Only the server thread touches it.
+ private static final Map RESTING = new WeakHashMap<>();
+
+ // forgetAll can come from an ItemsAdder event on any thread, so it only moves this on and rests from before end.
+ private static final AtomicInteger GENERATION = new AtomicInteger();
+
+ private SlotAging() {}
+
+ /** True while the slot still holds the item the timer last left there and that item can't look different yet. */
+ public static boolean isResting(PlacedSlot slot, ItemStack stack) {
+ Resting resting = RESTING.get(slot);
+ if (resting == null) {
+ return false;
+ }
+ if (resting.generation() == GENERATION.get() && resting.until() > StackNormalizer.quantizedNow()
+ && resting.item().equals(stack)) {
+ return true;
+ }
+ RESTING.remove(slot);
+ return false;
+ }
+
+ /**
+ * Ages the food (parsed from stack) in a slot of the furniture whose id is furniture, and returns what to put in
+ * the slot, or null to leave it. A food left as it was rests until it can look different; a written one is looked
+ * at again on the next pass, as the scan does.
+ */
+ public static ItemStack age(PlacedSlot slot, ItemStack stack, FoodItem food, String furniture) {
+ // Read first: a rest must not outlive a forgetAll that came while this food was being built.
+ int generation = GENERATION.get();
+ ItemMeta meta = stack.getItemMeta();
+ FoodScan.Result result = FoodScan.update(stack, meta, food, false, furniture);
+ if (result.item() == null && result.recheckAt() != ItemScanHandler.EVERY_SCAN) {
+ RESTING.put(slot, new Resting(stack.clone(), result.recheckAt(), generation));
+ } else {
+ RESTING.remove(slot);
+ }
+ return result.item();
+ }
+
+ /** Ends every skip, so each slot's food is looked at again: its config or item templates may have changed. */
+ public static void forgetAll() {
+ GENERATION.incrementAndGet();
+ }
+}
diff --git a/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java b/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java
index 45c0d4a..6a85830 100644
--- a/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java
+++ b/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java
@@ -39,6 +39,7 @@
import net.tfminecraft.cooking.utils.InventoryAdder;
import net.tfminecraft.cooking.utils.ItemBuilder;
import net.tfminecraft.cooking.utils.ItemUpdater;
+import net.tfminecraft.cooking.utils.SlotAging;
import net.tfminecraft.cooking.utils.WarmthUtils;
import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent;
import net.tfminecraft.interactiblefurniture.furniture.Furniture;
@@ -413,6 +414,15 @@ void agingVisitsLooseAndNestedTraysOnceAndIgnoresUnsupportedContents() {
verify(nested, times(1)).forceModel(any()); verify(separate, times(1)).forceModel(any());
}
+ @Test
+ void restingBreadIsLeftAloneUntilItCanLookDifferent() {
+ ItemStack original = bakery.loaf(0, 1); PlacedSlot slot = tray.place("left_a", original);
+ bakery.aging.when(() -> SlotAging.isResting(slot, original)).thenReturn(true);
+ new BakingTrayAging().start(); env.server.getScheduler().performTicks(20);
+ assertSame(original, slot.getCurrentItem()); verify(slot, never()).forceModel(any());
+ bakery.aging.verify(() -> SlotAging.age(any(), any(), any(), any()), never()); verify(env.manager, never()).markDirty(tray.furniture);
+ }
+
@Test
void emptyEntriesInThePublicSlotMapDoNotPreventOtherLoavesFromAging() {
tray.furniture.getActiveSlots().put("left_a", null);
@@ -430,6 +440,7 @@ public static final class Bakery implements AutoCloseable {
public final Set consumers = new HashSet<>();
public final Map> nested = new LinkedHashMap<>();
public final MockedStatic updater;
+ public final MockedStatic aging;
public final MockedStatic heat;
public final Station tray;
public BakingTrayRecipe recipe;
@@ -445,6 +456,9 @@ public Bakery() {
updater = env.scoped(mockStatic(ItemUpdater.class));
updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(0), inv.getArgument(1)));
updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(0), inv.getArgument(1)));
+ // The timer ages through SlotAging; route it to the stubbed ItemUpdater (SlotAgingTest covers the real one).
+ aging = env.scoped(mockStatic(SlotAging.class));
+ aging.when(() -> SlotAging.age(any(), any(), any(), any())).thenAnswer(inv -> ItemUpdater.updateItem(inv.getArgument(1), inv.getArgument(2), inv.getArgument(3)));
heat = env.scoped(mockStatic(HeatSources.class));
heat.when(() -> HeatSources.isConsumer(any())).thenAnswer(inv -> consumers.contains(inv.getArgument(0)));
heat.when(() -> HeatSources.consumerHasHeat(any())).thenAnswer(inv -> heated);
diff --git a/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java b/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java
index 4c72b47..f0f8bf2 100644
--- a/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java
+++ b/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java
@@ -56,6 +56,7 @@
import net.tfminecraft.cooking.utils.ItemBuilder;
import net.tfminecraft.cooking.utils.ItemRef;
import net.tfminecraft.cooking.utils.ItemUpdater;
+import net.tfminecraft.cooking.utils.SlotAging;
import net.tfminecraft.cooking.utils.Keys;
import net.tfminecraft.interactiblefurniture.events.FurniturePunchEvent;
import net.tfminecraft.interactiblefurniture.furniture.Furniture;
@@ -71,6 +72,7 @@ class CarvingAndMeatHookCoverageTest {
MockedStatic parser;
MockedStatic refs;
MockedStatic updater;
+ MockedStatic aging;
String oldHook, oldTool;
FoodItem lastPart;
@@ -97,6 +99,9 @@ class CarvingAndMeatHookCoverageTest {
updater = env.scoped(mockStatic(ItemUpdater.class));
updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(1)));
updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(1)));
+ // The hook's timer ages through SlotAging; route it to the stubbed ItemUpdater (SlotAgingTest covers the real one).
+ aging = env.scoped(mockStatic(SlotAging.class));
+ aging.when(() -> SlotAging.age(any(), any(), any(), any())).thenAnswer(inv -> ItemUpdater.updateItem(inv.getArgument(1), inv.getArgument(2), inv.getArgument(3)));
load("""
roast:
start-remaining: 3
@@ -412,6 +417,24 @@ void meatHookTimersAgeOnlyReadableRoastsAndRefreshTheirStageDisplay() {
verify(slot).setCurrentItem(any()); verify(slot).applyDisplayData(any()); verify(env.manager).markDirty(hook.furniture);
}
+ @Test
+ void carvingAgesTheRoastToNowBeforeTheCutsTakeItsNumbers() {
+ // The hook leaves a resting roast's stored numbers alone, so they can be well behind the clock.
+ FoodItem roast = roast("roast"); track(roast, "freshness", 0); roast.setLastUpdate(System.currentTimeMillis() - 5_000L);
+ PlacedSlot slot = hook.place("content", whole(roast));
+ assertTrue(carve(slot));
+ assertTrue(lastPart.getTagTrack("freshness").getValue() >= 5, "the cut keeps the roast's five seconds of aging");
+ }
+
+ @Test
+ void aRestingRoastKeepsItsItemAndDisplayUntilItCanLookDifferent() {
+ PlacedSlot slot = placeRoast(); ItemStack original = slot.getCurrentItem();
+ aging.when(() -> SlotAging.isResting(slot, original)).thenReturn(true);
+ hooks.start(); env.server.getScheduler().performTicks(20);
+ assertSame(original, slot.getCurrentItem()); verify(slot, never()).setCurrentItem(any()); verify(slot, never()).applyDisplayData(any());
+ aging.verify(() -> SlotAging.age(any(), any(), any(), any()), never()); verify(env.manager, never()).markDirty(hook.furniture);
+ }
+
@Test
void airAgingResultsCannotReplaceAHangingRoast() {
PlacedSlot slot = placeRoast(); ItemStack original = slot.getCurrentItem();
diff --git a/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java b/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java
index 47b45df..9138397 100644
--- a/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java
+++ b/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java
@@ -327,7 +327,8 @@ void cavityClocksPauseWithoutHeatThenCookAndBurnEachLoafAtItsOwnThreshold() {
assertEquals(2, env.resolve(bakery.tray.active.get("left_b").getCurrentItem()).getTagTrack("cooked").getValue());
assertEquals(0, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "right_a"));
assertEquals(0, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "right_b"));
- verify(env.manager, times(2)).markDirty(bakery.tray.furniture);
+ // Once per heated second: the bake times advance and are saved with them.
+ verify(env.manager, times(4)).markDirty(bakery.tray.furniture);
}
@Test
@@ -374,6 +375,8 @@ void failedBakingRenderDoesNotReplaceTheDisplayedLoafOrClaimASuccessfulChange()
bakery.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null);
new OvenCavityManager().start(); env.server.getScheduler().performTicks(40);
assertSame(original, slot.getCurrentItem()); verify(slot, never()).forceModel(any());
- verify(env.manager, never()).markDirty(bakery.tray.furniture);
+ // Only the bake time advanced (and is saved); the loaf itself stays as it was.
+ assertEquals(2, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "left_a"));
+ verify(env.manager, times(2)).markDirty(bakery.tray.furniture);
}
}
diff --git a/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java b/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java
index c53cd9e..9d4f662 100644
--- a/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java
+++ b/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java
@@ -25,6 +25,7 @@
import net.tfminecraft.cooking.utils.IngredientConverter;
import net.tfminecraft.cooking.utils.InventoryAdder;
import net.tfminecraft.cooking.utils.ItemUpdater;
+import net.tfminecraft.cooking.utils.SlotAging;
class TroughCoverageTest {
Kitchen kitchen;
@@ -32,6 +33,7 @@ class TroughCoverageTest {
Station trough;
TroughHandler handler;
MockedStatic converter;
+ MockedStatic aging;
String previousTrough, previousFeed;
int previousCount;
@@ -43,6 +45,9 @@ class TroughCoverageTest {
when(env.api.getCreator().getItemFromPath("feed")).thenAnswer(inv -> new ItemStack(Material.WHEAT));
converter = env.scoped(mockStatic(IngredientConverter.class));
converter.when(() -> IngredientConverter.convertIfNeeded(any(), any())).thenAnswer(inv -> inv.getArgument(1));
+ // The timer ages through SlotAging; route it to the kitchen's stubbed ItemUpdater (SlotAgingTest covers the real one).
+ aging = env.scoped(mockStatic(SlotAging.class));
+ aging.when(() -> SlotAging.age(any(), any(), any(), any())).thenAnswer(inv -> ItemUpdater.updateItem(inv.getArgument(1), inv.getArgument(2), inv.getArgument(3)));
}
@AfterEach void close() {
kitchen.close(); FurnitureCache.trough = previousTrough; ItemCache.troughFeed = previousFeed; ItemCache.troughItemsPerClick = previousCount;
@@ -132,6 +137,14 @@ void invalidBatchAmountsNeverConsumeOrStoreFood(int count) {
trough.place("a", new ItemStack(Material.STONE)); when(trough.furniture.getLoc()).thenReturn(new Location(null, 0, 0, 0)); env.server.getScheduler().performTicks(20); assertFalse(trough.active.containsKey("a")); assertEquals(1, env.dropped.size());
}
+ @Test void restingFoodIsLeftAloneUntilItCanLookDifferent() {
+ ItemStack original = env.stack(carrot(), Material.CARROT, 3); var slot = trough.place("a", original);
+ aging.when(() -> SlotAging.isResting(slot, original)).thenReturn(true);
+ new TroughAging().start(); env.server.getScheduler().performTicks(20);
+ assertSame(original, slot.getCurrentItem()); verify(slot, never()).forceModel(any()); assertTrue(env.dropped.isEmpty());
+ aging.verify(() -> SlotAging.age(any(), any(), any(), any()), never()); verify(env.manager, never()).markDirty(trough.furniture);
+ }
+
@Test void failedAgingRenderingNeverDeletesStoredFood() {
ItemStack original = env.stack(carrot(), Material.CARROT, 3); var slot = trough.place("a", original);
kitchen.updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(new ItemStack(Material.AIR));
diff --git a/src/test/java/net/tfminecraft/cooking/utils/SlotAgingTest.java b/src/test/java/net/tfminecraft/cooking/utils/SlotAgingTest.java
new file mode 100644
index 0000000..9e72efc
--- /dev/null
+++ b/src/test/java/net/tfminecraft/cooking/utils/SlotAgingTest.java
@@ -0,0 +1,229 @@
+package net.tfminecraft.cooking.utils;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.bukkit.Material;
+import org.bukkit.configuration.MemoryConfiguration;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockito.MockedStatic;
+
+import net.tfminecraft.cooking.Cooking;
+import net.tfminecraft.cooking.item.FoodItem;
+import net.tfminecraft.cooking.item.model.FoodModel;
+import net.tfminecraft.cooking.item.tag.TagStep;
+import net.tfminecraft.cooking.item.tag.TagTrack;
+import net.tfminecraft.cooking.loader.FoodLoader;
+import net.tfminecraft.cooking.loader.TrackLoader;
+import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot;
+
+class SlotAgingTest {
+ static final String HOOK = "meat_hook";
+
+ Cooking plugin;
+ List foods;
+ List tracks;
+ MockedStatic clock;
+ PlacedSlot slot;
+ long now;
+
+ @BeforeEach
+ void setup() {
+ MockBukkit.mock();
+ plugin = Cooking.plugin;
+ Cooking.plugin = mock(Cooking.class);
+ when(Cooking.plugin.getName()).thenReturn("Cooking");
+ when(Cooking.plugin.namespace()).thenReturn("cooking");
+ foods = new ArrayList<>(FoodLoader.oList);
+ FoodLoader.oList.clear();
+ tracks = new ArrayList<>(TrackLoader.oList);
+ TrackLoader.oList.clear();
+ TrackLoader.oList.add(new TagTrack("freshness", true, List.of(
+ new TagStep("fresh", "Fresh", 0, 1, 1),
+ new TagStep("aging", "Aging", 4, .9, .9),
+ new TagStep("stale", "Stale", 9, .7, .7))));
+ TrackLoader.oList.add(new TagTrack("warmth", true, List.of(
+ new TagStep("hot", "Hot", 0, 1.2, 1.2),
+ new TagStep("warm", "Warm", 3, 1.1, 1.1),
+ new TagStep("cold", "Cold", 20, 1, 1))));
+ clock = mockStatic(StackNormalizer.class, CALLS_REAL_METHODS);
+ clock.when(StackNormalizer::quantizedNow).thenAnswer(inv -> now);
+ slot = mock(PlacedSlot.class);
+ SlotAging.forgetAll();
+ }
+
+ @AfterEach
+ void cleanup() {
+ SlotAging.forgetAll();
+ clock.close();
+ FoodLoader.oList.clear();
+ FoodLoader.oList.addAll(foods);
+ TrackLoader.oList.clear();
+ TrackLoader.oList.addAll(tracks);
+ MockBukkit.unmock();
+ Cooking.plugin = plugin;
+ }
+
+ /**
+ * The hook, tray and trough timers used to rewrite their food every second (ItemUpdater.updateItem). Skipping
+ * resting slots must leave food that looks exactly the same after every second, through steps and warmth loss,
+ * with one write per visible change (plus setting a clock that started off the second once).
+ */
+ @Test
+ void restingSlotsLookExactlyLikeRewritingEverySecond() {
+ for (long start : new long[] {10_000L, 10_300L}) {
+ SlotAging.forgetAll();
+ ItemStack old = warmRoast(start);
+ ItemStack aged = old.clone();
+ int writes = 0;
+ int visibleChanges = 0;
+ now = 10_000L;
+ for (int second = 0; second < 100; second++) {
+ now += 1000L;
+ List loreBefore = lore(old);
+ ItemStack rewritten = ItemUpdater.updateItem(old, FoodItem.fromItem(old), HOOK);
+ if (rewritten != null) old = rewritten;
+ if (!lore(old).equals(loreBefore)) visibleChanges++;
+ if (!SlotAging.isResting(slot, aged)) {
+ ItemStack written = SlotAging.age(slot, aged, FoodItem.fromItem(aged), HOOK);
+ if (written != null) {
+ aged = written;
+ writes++;
+ }
+ }
+ assertEquals(lore(old), lore(aged), "at " + now);
+ assertEquals(steps(old), steps(aged), "at " + now);
+ }
+ assertTrue(visibleChanges >= 3, "the roast should go aging, warm, stale and lose its warmth");
+ assertEquals(visibleChanges + (start % 1000 == 0 ? 0 : 1), writes);
+ assertTrue(SlotAging.isResting(slot, aged), "stale, cold food never changes again");
+ }
+ }
+
+ @Test
+ void aRestEndsWhenTheItemChangesOrItsTimeComes() {
+ now = 10_000L;
+ ItemStack roast = warmRoast(now);
+ assertFalse(SlotAging.isResting(slot, roast));
+ now = 11_000L;
+ assertNull(SlotAging.age(slot, roast, FoodItem.fromItem(roast), HOOK));
+ assertTrue(SlotAging.isResting(slot, roast));
+ assertTrue(SlotAging.isResting(slot, roast.clone()), "an equal item rests too");
+ assertFalse(SlotAging.isResting(mock(PlacedSlot.class), roast), "rests are per slot");
+
+ ItemStack two = roast.clone();
+ two.setAmount(2);
+ assertFalse(SlotAging.isResting(slot, two), "another item ends the rest");
+ assertFalse(SlotAging.isResting(slot, roast), "and it stays ended");
+
+ assertNull(SlotAging.age(slot, roast, FoodItem.fromItem(roast), HOOK));
+ // Started at 10 s, it goes aging at 18 s (warm at 22 s): due from 17 s, a second early.
+ now = 16_000L;
+ assertTrue(SlotAging.isResting(slot, roast));
+ now = 17_000L;
+ assertFalse(SlotAging.isResting(slot, roast));
+ }
+
+ @Test
+ void writtenFoodIsLookedAtAgainOnTheNextPass() {
+ now = 11_000L;
+ ItemStack offTheSecond = warmRoast(10_300L);
+ ItemStack written = SlotAging.age(slot, offTheSecond, FoodItem.fromItem(offTheSecond), HOOK);
+ assertNotNull(written);
+ assertFalse(SlotAging.isResting(slot, written));
+ assertNull(SlotAging.age(slot, written, FoodItem.fromItem(written), HOOK));
+ assertTrue(SlotAging.isResting(slot, written));
+ }
+
+ @Test
+ void foodIsRebuiltWithTheFurnituresModel() {
+ now = 11_000L;
+ ItemStack offTheSecond = warmRoast(10_300L);
+ ItemStack resting = warmRoast(11_000L);
+ try (MockedStatic updater = mockStatic(ItemUpdater.class, CALLS_REAL_METHODS)) {
+ SlotAging.age(slot, offTheSecond, FoodItem.fromItem(offTheSecond), HOOK);
+ SlotAging.age(slot, resting, FoodItem.fromItem(resting), HOOK);
+ updater.verify(() -> ItemUpdater.applyItemUpdate(any(), any(), eq(HOOK)), times(2));
+ updater.verify(() -> ItemUpdater.applyItemUpdate(any(), any(), isNull()), never());
+ }
+ }
+
+ @Test
+ void sauceFoodIsRewrittenEveryPassAndNeverRests() {
+ FoodItem dish = new FoodItem(food("plate", new MemoryConfiguration()));
+ FoodItem gravy = new FoodItem(food("gravy", new MemoryConfiguration()));
+ gravy.addOrModifyTrack(TrackLoader.getByString("freshness"));
+ dish.setSauce(gravy);
+ dish.setLastUpdate(10_000L);
+ ItemStack stack = ItemBuilder.stamp(new ItemStack(Material.BOWL), dish, "Plate");
+ now = 12_000L;
+ ItemStack written;
+ try (MockedStatic updater = mockStatic(ItemUpdater.class, CALLS_REAL_METHODS)) {
+ written = SlotAging.age(slot, stack, FoodItem.fromItem(stack), HOOK);
+ updater.verify(() -> ItemUpdater.updateItem(any(), any(), eq(HOOK), eq(false)));
+ }
+ assertNotNull(written);
+ assertFalse(SlotAging.isResting(slot, written));
+ assertNull(SlotAging.age(slot, written, FoodItem.fromItem(written), HOOK), "nothing new this second");
+ assertFalse(SlotAging.isResting(slot, written), "but it is looked at again next second");
+ }
+
+ @Test
+ void foodThatNeverAgesRestsUntilItChangesAndReloadsEndEveryRest() {
+ MemoryConfiguration config = new MemoryConfiguration();
+ config.set("update", false);
+ FoodItem salt = food("salt", config);
+ ItemStack stack = ItemBuilder.stamp(new ItemStack(Material.SUGAR), new FoodItem(salt), "Salt");
+ now = 5_000L;
+ assertNull(SlotAging.age(slot, stack, FoodItem.fromItem(stack), HOOK));
+ now = Long.MAX_VALUE - 1;
+ assertTrue(SlotAging.isResting(slot, stack));
+ ItemRef.clearCache();
+ assertFalse(SlotAging.isResting(slot, stack), "a reload or /iareload ends every rest");
+ }
+
+ /** Fresh, hot roast whose clock reads start. Freshness ages at 2 s a step, warmth at 4 s. */
+ private ItemStack warmRoast(long start) {
+ MemoryConfiguration config = new MemoryConfiguration();
+ config.set("age.freshness", 2.0);
+ config.set("age.warmth", 4.0);
+ FoodItem roast = new FoodItem(food("roast", config));
+ roast.addOrModifyTrack(TrackLoader.getByString("freshness"));
+ roast.addOrModifyTrack(TrackLoader.getByString("warmth"));
+ roast.setLastUpdate(start);
+ // Built food always has a model; without one, the first rewrite would add it.
+ roast.setModel(new FoodModel(new ItemStack(Material.COOKED_BEEF)));
+ return ItemBuilder.stamp(new ItemStack(Material.COOKED_BEEF), roast, "Roast");
+ }
+
+ @SuppressWarnings("deprecation")
+ private static List lore(ItemStack stack) {
+ ItemMeta meta = stack.getItemMeta();
+ return meta.getLore();
+ }
+
+ private static List steps(ItemStack stack) {
+ List steps = new ArrayList<>();
+ for (TagTrack track : FoodItem.fromItem(stack).getTagTracks()) {
+ steps.add(track.getId() + "=" + track.getCurrentStep().getId());
+ }
+ return steps;
+ }
+
+ private FoodItem food(String id, MemoryConfiguration config) {
+ config.set("name", id);
+ FoodItem food = new FoodItem(id, config);
+ FoodLoader.oList.removeIf(existing -> existing.getId().equals(id));
+ FoodLoader.oList.add(food);
+ return food;
+ }
+}