From 46d5fa3ca1015d42543e62edf5f019534378c655 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:03:23 +0100 Subject: [PATCH] perf: skip metadata copies in the sketch kit pass, remember the tracker hand check, look up Hand Pick state once A one-hour Main spark profile at 60 players put SketchService.stampKitsLater at 8.0 s, TrackerService.pulse at 3.6 s and the Hand Pick HUD pass at 8.6 s of main-thread time. These changes keep every result the same. - Sketch kits: markUnstackable checks the item type before copying metadata (only a filled map has MapMeta); stampInstructions matches a stack only against a vanilla sheet or pencil, since pack supplies never get Archaeo lore; several stampKitsLater calls for one player in one tick scan the bag once, in the newest request. - Tracker: holdingTracker remembers both hand stacks and the answer, and matches again when a stack changes (ItemStack equality), after 20 ticks, on reload, or when the pack-plugin matcher is rebound. - ItemMatcher: the MMOItems id is read only when the type already fits; type and id are separate reads of the item data. - Hand Pick: the per-player shortcut state is looked up once per player per tick and passed to the held-tool match and the mining-lock steps. The per-tick action-bar send is unchanged: MMOCore redraws its own bar and Archaeo does not reserve it, so sending less often could let that bar show through. Co-Authored-By: Claude Fable 5.2 --- .../tfminecraft/archaeo/ArcheologyPlugin.java | 1 + .../archaeo/excavation/HandPickService.java | 40 +++++++----- .../tfminecraft/archaeo/item/ItemMatcher.java | 7 ++- .../archaeo/item/SketchSupplies.java | 8 ++- .../archaeo/sketch/SketchService.java | 15 ++++- .../archaeo/tracker/TrackerService.java | 61 ++++++++++++++++++- .../archaeo/item/ItemMatcherPackIT.java | 2 + .../archaeo/item/SketchSuppliesTest.java | 7 +++ .../archaeo/sketch/SketchServiceTest.java | 32 ++++++++++ .../archaeo/tracker/TrackerServiceTest.java | 46 ++++++++++++++ 10 files changed, 196 insertions(+), 23 deletions(-) diff --git a/src/main/java/net/tfminecraft/archaeo/ArcheologyPlugin.java b/src/main/java/net/tfminecraft/archaeo/ArcheologyPlugin.java index 55b0dc9..f61ae79 100644 --- a/src/main/java/net/tfminecraft/archaeo/ArcheologyPlugin.java +++ b/src/main/java/net/tfminecraft/archaeo/ArcheologyPlugin.java @@ -194,6 +194,7 @@ public void bindItemMatcher(ItemMatcher matcher) { // Callers (enable, reload, the pack-plugin hook) run only after onEnable built every item. ItemMatcher bound = matcher == null ? ItemMatcher.vanillaOnly() : matcher; trackerItem.setMatcher(bound); + tracker.itemChanged(); prospectItem.setMatcher(bound); establishItem.setMatcher(bound); brushItem.setMatcher(bound); diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/HandPickService.java b/src/main/java/net/tfminecraft/archaeo/excavation/HandPickService.java index a53c392..aebc15e 100644 --- a/src/main/java/net/tfminecraft/archaeo/excavation/HandPickService.java +++ b/src/main/java/net/tfminecraft/archaeo/excavation/HandPickService.java @@ -378,7 +378,7 @@ private void tick() { continue; } if (!net.tfminecraft.archaeo.ownership.CharacterActors.current(cycle.actor, player)) { finish(player); continue; } - ExcavationTool held = heldTool(player, player.getInventory().getItemInMainHand(), false); + ExcavationTool held = heldTool(state(player), player.getInventory().getItemInMainHand(), false); if (held == null || !held.id().equals(cycle.tool.id())) { finish(player); continue; @@ -423,19 +423,21 @@ private void tick() { * Per-tick {@link #syncHeldTool} plus the HUD, with one tool match and at most one crosshair * trace. The HUD shows while cycling, or while the crosshair is inside an established prism * (opened air included, so neighbour traces can sit on the empty cube like a minesweeper - * number). The mining lock covers cycling and aiming at prism fill. + * number). The mining lock covers cycling and aiming at prism fill. The player's shortcuts + * are looked up once here and handed to each step. * * @param player online player */ private void syncAndShowHud(Player player) { + HeldState state = state(player); ItemStack held = player.getInventory().getItemInMainHand(); - if (heldTool(player, held, true) == null) { - keepVanillaMineUnlocked(player); + if (heldTool(state, held, true) == null) { + keepVanillaMineUnlocked(player, state); return; } Cycle cycle = cycles.get(player.getUniqueId()); if (cycle != null) { - setVanillaMineLocked(player, true); + setVanillaMineLocked(player, state, true); Block cell = player.getWorld().getBlockAt(cycle.x, cycle.y, cycle.z); showHud(player, cycle, cell, establishedAt(cell)); return; @@ -443,9 +445,9 @@ private void syncAndShowHud(Player player) { Block target = aimedNearSite(player); Site site = target == null ? null : establishedAt(target); if (site != null && PrismFill.isTerrainFill(target.getType())) { - setVanillaMineLocked(player, true); + setVanillaMineLocked(player, state, true); } else { - keepVanillaMineUnlocked(player); + keepVanillaMineUnlocked(player, state); } if (site != null) { showHud(player, null, target, site); @@ -458,15 +460,14 @@ private void syncAndShowHud(Player player) { * {@link #RECHECK_TICKS}. Matching asks every whitelist entry and the pack plugins, which is * too slow to repeat for every online player on every tick. * - * @param player holder + * @param state the holder's shortcuts * @param stack current main-hand stack * @param restore whether to strip a legacy mine lock from a whitelisted stack, as * {@link DigTools#restoreAllowedTool} does; skipped while the stack is the one * already restored, since a restored stack has nothing left to strip * @return matching profile, or {@code null} when the stack is not an excavation tool */ - private ExcavationTool heldTool(Player player, ItemStack stack, boolean restore) { - HeldState state = state(player); + private ExcavationTool heldTool(HeldState state, ItemStack stack, boolean restore) { boolean same = stack != null && state.stack != null && gameTick - state.matchedTick < RECHECK_TICKS @@ -496,13 +497,13 @@ private ExcavationTool heldTool(Player player, ItemStack stack, boolean restore) * clears that confirmation when it does. * * @param player holder + * @param state the holder's shortcuts */ - private void keepVanillaMineUnlocked(Player player) { - HeldState state = state(player); + private void keepVanillaMineUnlocked(Player player, HeldState state) { if (state.unlocked && gameTick - state.unlockedTick < RECHECK_TICKS) { return; } - setVanillaMineLocked(player, false); + setVanillaMineLocked(player, state, false); } /** @@ -850,6 +851,18 @@ private void playClang(Player player, Block block) { * @param locked whether vanilla destroy progress must stay at zero on the client */ private void setVanillaMineLocked(Player player, boolean locked) { + setVanillaMineLocked(player, state(player), locked); + } + + /** + * {@link #setVanillaMineLocked(Player, boolean)} for a caller that already holds the + * player's shortcuts, so they are not looked up again. + * + * @param player holder + * @param state the holder's shortcuts + * @param locked whether vanilla destroy progress must stay at zero on the client + */ + private void setVanillaMineLocked(Player player, HeldState state, boolean locked) { AttributeInstance speed = player.getAttribute(Attribute.BLOCK_BREAK_SPEED); if (speed == null) { return; @@ -861,7 +874,6 @@ private void setVanillaMineLocked(Player player, boolean locked) { break; } } - HeldState state = state(player); state.unlocked = !locked; state.unlockedTick = gameTick; if (locked && found == null) { diff --git a/src/main/java/net/tfminecraft/archaeo/item/ItemMatcher.java b/src/main/java/net/tfminecraft/archaeo/item/ItemMatcher.java index 7c5f4f4..a7b2e65 100644 --- a/src/main/java/net/tfminecraft/archaeo/item/ItemMatcher.java +++ b/src/main/java/net/tfminecraft/archaeo/item/ItemMatcher.java @@ -278,11 +278,12 @@ private boolean matchesItemsAdder(Probe probe, ItemRef ref) { */ private boolean matchesMmoItems(Probe probe, ItemRef ref) { String type = probe.mmoType(); - String id = probe.mmoId(); - if (type == null || id == null) { + if (type == null || !type.equalsIgnoreCase(ref.primary())) { return false; } - return type.equalsIgnoreCase(ref.primary()) && id.equalsIgnoreCase(ref.secondary()); + // The id is a second read of the item's data, so it is asked only once the type fits. + String id = probe.mmoId(); + return id != null && id.equalsIgnoreCase(ref.secondary()); } /** diff --git a/src/main/java/net/tfminecraft/archaeo/item/SketchSupplies.java b/src/main/java/net/tfminecraft/archaeo/item/SketchSupplies.java index 5476f9a..5704c97 100644 --- a/src/main/java/net/tfminecraft/archaeo/item/SketchSupplies.java +++ b/src/main/java/net/tfminecraft/archaeo/item/SketchSupplies.java @@ -173,16 +173,20 @@ public boolean wear(Player player, ItemStack pencil) { /** * Restores Archaeo how-to lore on a vanilla kit copy this plugin created. Unmarked paper, * unmarked feathers, and pack items are left untouched so their own tooltip stays. + * A sheet or pencil configured as a pack item never gets lore, so its stacks are not + * matched at all: that match reads the item's data through the pack plugin. * * @param stack possible kit item * @return whether lore was written */ public boolean stampInstructions(ItemStack stack) { - if (isPaper(stack) && isMarkedKit(stack, true) && ownsTooltip(paper, stack)) { + if (paper.kind() == ItemRef.Kind.VANILLA + && isPaper(stack) && isMarkedKit(stack, true) && ownsTooltip(paper, stack)) { stamped(stack, true); return true; } - if (isPencil(stack) && isMarkedKit(stack, false) && ownsTooltip(pencil, stack)) { + if (pencil.kind() == ItemRef.Kind.VANILLA + && isPencil(stack) && isMarkedKit(stack, false) && ownsTooltip(pencil, stack)) { stamped(stack, false); return true; } diff --git a/src/main/java/net/tfminecraft/archaeo/sketch/SketchService.java b/src/main/java/net/tfminecraft/archaeo/sketch/SketchService.java index 3eb144b..ff6ef4c 100644 --- a/src/main/java/net/tfminecraft/archaeo/sketch/SketchService.java +++ b/src/main/java/net/tfminecraft/archaeo/sketch/SketchService.java @@ -78,6 +78,8 @@ public class SketchService { private final Map sessions = new HashMap<>(); private final Map sheets = new HashMap<>(); private final Map bars = new HashMap<>(); + /** Newest queued {@link #stampKitsLater} request per player; an older one leaves the pass to it. */ + private final Map kitPasses = new HashMap<>(); private BukkitTask task; private int autosaveClock; @@ -600,12 +602,20 @@ private void handBackCabinet(Player player, Inventory top) { /** * Unstacks sketch maps and restores how-to lore on Archaeo-given vanilla kits only. + * Several inventory events in one tick (a pile of drops picked up, a click and a close) each + * ask before the scheduler runs; only the newest request for a player scans the bag, since + * it runs last and repeats the same pass. * * @param player holder */ public void stampKitsLater(Player player) { var actor=net.tfminecraft.archaeo.ownership.CharacterActors.session(player).orElse(null); + Object pass = new Object(); + kitPasses.put(player.getUniqueId(), pass); plugin.getServer().getScheduler().runTask(plugin, () -> { + if (!kitPasses.remove(player.getUniqueId(), pass)) { + return; + } if (!player.isOnline()||!net.tfminecraft.archaeo.ownership.CharacterActors.current(actor,player)) { return; } @@ -1316,11 +1326,14 @@ private ItemStack duplicateSketch(World world, ItemStack original) { /** * Filled maps with the same view would otherwise stack; each sketch is one sheet. + * Only a filled map carries {@link MapMeta}, so every other item is skipped by its type + * without copying its metadata. * * @param stack sketch, or empty */ private static void markUnstackable(ItemStack stack) { - if (stack == null || !(stack.getItemMeta() instanceof MapMeta meta)) { + if (stack == null || stack.getType() != Material.FILLED_MAP + || !(stack.getItemMeta() instanceof MapMeta meta)) { return; } meta.setMaxStackSize(1); diff --git a/src/main/java/net/tfminecraft/archaeo/tracker/TrackerService.java b/src/main/java/net/tfminecraft/archaeo/tracker/TrackerService.java index a99f1eb..bbb6e46 100644 --- a/src/main/java/net/tfminecraft/archaeo/tracker/TrackerService.java +++ b/src/main/java/net/tfminecraft/archaeo/tracker/TrackerService.java @@ -11,12 +11,15 @@ import org.bukkit.SoundCategory; import org.bukkit.World; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.scheduler.BukkitTask; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; +import java.util.WeakHashMap; import java.util.concurrent.ConcurrentHashMap; /** @@ -24,6 +27,12 @@ * how well that look lines up with a hidden ruin and how far it is. */ public class TrackerService { + /** + * Ticks an unchanged pair of hands keeps its last answer before the tracker item is matched + * again (pack registries can reload under an unchanged stack). + */ + private static final int RECHECK_TICKS = 20; + private final JavaPlugin plugin; private final SiteRepository sites; private final TrackerItem item; @@ -31,6 +40,8 @@ public class TrackerService { private final Map lastBeepTick = new ConcurrentHashMap<>(); private final Map lastDetectMessageTick = new ConcurrentHashMap<>(); private final Map lockedSiteId = new ConcurrentHashMap<>(); + /** Last hands checked per online player object; an entry goes when the player object does. */ + private final Map held = new WeakHashMap<>(); private int tick; private BukkitTask task; @@ -48,10 +59,19 @@ public TrackerService(JavaPlugin plugin, SiteRepository sites, TrackerItem item, } /** - * @param settings latest values after a catalog reload + * @param settings latest values after a catalog reload, which can also change {@code tracker.item} */ public void setSettings(TrackerSettings settings) { this.settings = settings; + itemChanged(); + } + + /** + * Forgets every remembered hand check, so the next pulse matches again. Call after + * {@code tracker.item} or the pack-plugin lookup behind it changes. + */ + public void itemChanged() { + held.clear(); } /** @@ -73,6 +93,7 @@ public void stop() { lastBeepTick.clear(); lastDetectMessageTick.clear(); lockedSiteId.clear(); + held.clear(); } /** @@ -108,12 +129,35 @@ public void pulse() { } /** + * Matches the hands again only when either stack differs from the pair checked last (any + * change to type, amount, or data) or after {@link #RECHECK_TICKS}. Matching a pack tracker + * reads the item's data through the pack plugin, which is too slow to repeat for every + * online player on every pulse. + * * @param player online player * @return {@code true} if main or off hand is a tracker */ private boolean holdingTracker(Player player) { - return item.isTracker(player.getInventory().getItemInMainHand()) - || item.isTracker(player.getInventory().getItemInOffHand()); + ItemStack main = player.getInventory().getItemInMainHand(); + ItemStack off = player.getInventory().getItemInOffHand(); + HeldHands hands = held.get(player); + if (hands != null + && tick - hands.checkedTick < RECHECK_TICKS + && Objects.equals(hands.main, main) + && Objects.equals(hands.off, off)) { + return hands.tracker; + } + boolean tracker = item.isTracker(main) || item.isTracker(off); + held.put(player, new HeldHands(copy(main), copy(off), tracker, tick)); + return tracker; + } + + /** + * @param stack live hand stack, or {@code null} + * @return a copy later changes to the live stack cannot reach + */ + private static ItemStack copy(ItemStack stack) { + return stack == null ? null : stack.clone(); } /** @@ -496,6 +540,17 @@ private void sendDetectIfReady(Player scanner) { scanner.sendMessage("Prospect the area to determine the site location."); } + /** + * One player's hands as last checked for a tracker. + * + * @param main copy of the main-hand stack + * @param off copy of the off-hand stack + * @param tracker whether either was the tracker + * @param checkedTick service tick of that check + */ + private record HeldHands(ItemStack main, ItemStack off, boolean tracker, int checkedTick) { + } + /** * Hidden ruin still inside its detection bubble and in front of the scanner. * diff --git a/src/test/java/net/tfminecraft/archaeo/item/ItemMatcherPackIT.java b/src/test/java/net/tfminecraft/archaeo/item/ItemMatcherPackIT.java index 67814ce..33e3278 100644 --- a/src/test/java/net/tfminecraft/archaeo/item/ItemMatcherPackIT.java +++ b/src/test/java/net/tfminecraft/archaeo/item/ItemMatcherPackIT.java @@ -170,6 +170,8 @@ public class ItemMatcherPackIT { } assertTrue(matcher.matches(null, ItemRef.air())); assertTrue(matcher.matches(dirt, ItemRef.vanilla(Material.DIRT))); assertFalse(matcher.isCustom(dirt)); + // The id is a second read of the item's data; a stack without the wanted type is not asked for it. + mi.verify(() -> mmoApi.getMethod("getID", ItemStack.class).invoke(null, dirt), never()); mi.when(() -> mmoApi.getMethod("getTypeName", ItemStack.class).invoke(null, dirt)).thenReturn("TOOL"); assertFalse(matcher.matches(dirt, ref("mi:tool:brush"))); } diff --git a/src/test/java/net/tfminecraft/archaeo/item/SketchSuppliesTest.java b/src/test/java/net/tfminecraft/archaeo/item/SketchSuppliesTest.java index 39c995d..1e7d467 100644 --- a/src/test/java/net/tfminecraft/archaeo/item/SketchSuppliesTest.java +++ b/src/test/java/net/tfminecraft/archaeo/item/SketchSuppliesTest.java @@ -138,6 +138,8 @@ public void packPencilGivenByArchaeoKeepsThePackTooltipWhenRestamped() { when(matcher.create(packPencil)).thenReturn(custom); supplies.setMatcher(matcher); ItemStack given = supplies.createPencil(); when(matcher.matches(given, packPencil)).thenReturn(true); assertFalse(supplies.stampInstructions(given)); assertEquals(List.of("Pack pencil"), given.getItemMeta().getLore()); + // A pack pencil can never be stamped, so the pack plugin is not asked whether this stack is one. + verify(matcher, never()).matches(given, packPencil); verify(matcher).matches(given, paper); // Switching sketch.pencil back to a vanilla stick must not overwrite pencils already handed out from the pack. ItemRef vanillaStick = ItemRef.vanilla(Material.STICK); supplies.update(paper, vanillaStick, 0); when(matcher.matches(given, vanillaStick)).thenReturn(true); when(matcher.isCustom(given)).thenReturn(true); @@ -160,6 +162,11 @@ public void packItemsKeepNamesAndLoreAndMissingTemplatesStayMissing() { assertEquals(List.of("Pack instructions"), custom.getItemMeta().getLore()); when(matcher.matches(custom, customRef)).thenReturn(true); assertFalse(supplies.stampInstructions(custom)); + // With both supplies from a pack, scanning a bag reads no item through the pack plugins. + supplies.stampAll(new ItemStack[]{custom, null, new ItemStack(Material.FEATHER)}); + verify(matcher, never()).matches(any(), any()); + verify(matcher, never()).isCustom(any()); + assertEquals(List.of("Pack instructions"), custom.getItemMeta().getLore()); when(matcher.create(customRef)).thenReturn(null); assertNull(supplies.createPaper()); when(matcher.create(customRef)).thenReturn(new ItemStack(Material.AIR)); diff --git a/src/test/java/net/tfminecraft/archaeo/sketch/SketchServiceTest.java b/src/test/java/net/tfminecraft/archaeo/sketch/SketchServiceTest.java index eec435a..0621e80 100644 --- a/src/test/java/net/tfminecraft/archaeo/sketch/SketchServiceTest.java +++ b/src/test/java/net/tfminecraft/archaeo/sketch/SketchServiceTest.java @@ -190,6 +190,38 @@ public class SketchServiceTest { assertTrue(player.getInventory().getItem(4).getItemMeta().hasLore()); } + @Test public void kitRequestsQueuedInOneTickScanTheBagOnceForEachPlayer() { + SketchSupplies counted = spy(supplies); + SketchService queued = new SketchService(plugin, counted, recovered, sites, catalogs, SketchSettings.defaults()); + ItemStack sheet = supplies.createPaper(); var meta = sheet.getItemMeta(); meta.setLore(null); sheet.setItemMeta(meta); + player.getInventory().setItem(4, sheet); + // Three inventory events in one tick; the newest request runs last and makes the only pass. + queued.stampKitsLater(player); queued.stampKitsLater(player); queued.stampKitsLater(player); + server.getScheduler().performOneTick(); + verify(counted, times(1)).stampAll(any()); + assertTrue(player.getInventory().getItem(4).getItemMeta().hasLore()); + // Nothing is left queued, and a request in a later tick is a pass of its own. + server.getScheduler().performOneTick(); + verify(counted, times(1)).stampAll(any()); + queued.stampKitsLater(player); server.getScheduler().performOneTick(); + verify(counted, times(2)).stampAll(any()); + // Another player's request never stands in for this one's. + PlayerMock other = server.addPlayer(); other.getInventory().setItem(2, supplies.createPencil()); + queued.stampKitsLater(player); queued.stampKitsLater(other); queued.stampKitsLater(player); + server.getScheduler().performOneTick(); + verify(counted, times(4)).stampAll(any()); + } + + @Test public void unstackingCarriedSketchesCopiesMetadataOnlyForFilledMaps() { + ItemStack stone = spy(new ItemStack(Material.STONE, 3)); ItemStack plain = new ItemStack(Material.FILLED_MAP); + org.bukkit.entity.Player carrier = mock(org.bukkit.entity.Player.class); PlayerInventory bag = mock(PlayerInventory.class); + when(carrier.getInventory()).thenReturn(bag); when(bag.getContents()).thenReturn(new ItemStack[]{stone, null, plain}); + service.unstackCarried(carrier); + verify(stone, never()).getItemMeta(); assertEquals(3, stone.getAmount()); + // Any filled map, sketch or not, is still capped at one per slot. + assertEquals(1, plain.getItemMeta().getMaxStackSize()); + } + @Test public void cabinetAcceptsOnlySignedDrawingsAndDoesNotOverwriteExistingDeposit() { Inventory cabinet = server.createInventory(null, 9); ItemStack unsigned = service.createUnsigned(player.getWorld()); diff --git a/src/test/java/net/tfminecraft/archaeo/tracker/TrackerServiceTest.java b/src/test/java/net/tfminecraft/archaeo/tracker/TrackerServiceTest.java index c4f0005..5f51e05 100644 --- a/src/test/java/net/tfminecraft/archaeo/tracker/TrackerServiceTest.java +++ b/src/test/java/net/tfminecraft/archaeo/tracker/TrackerServiceTest.java @@ -238,6 +238,52 @@ public void onChunkPulsesKeepChimingButTheHintRepeatsOnlyAfterItsCooldown() { assertNull(player.nextMessage()); } + @Test + public void handsAreMatchedOncePerPairUntilAStackChangesOrASecondPasses() { + TrackerItem item = spy(new TrackerItem(ItemRef.vanilla(Material.COMPASS))); + SiteRepository repository = mock(SiteRepository.class); + when(repository.all()).thenAnswer(invocation -> sites); + when(repository.findById(any())).thenAnswer(invocation -> sites.stream().filter(s -> s.getId().equals(invocation.getArgument(0))).findFirst()); + tracker = new TrackerService(MockBukkit.createMockPlugin(), repository, item, settings(true, false)); + site(0, 0); + player.teleport(new Location(world, 8, 5, 8)); + player.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); + tracker.pulse(); + verify(item, times(2)).isTracker(any()); + // Nine more pulses stay inside the second: neither hand is asked again. + for (int pulse = 0; pulse < 9; pulse++) tracker.pulse(); + verify(item, times(2)).isTracker(any()); + // A second later the unchanged hands are asked again, in case a pack plugin reloaded. + tracker.pulse(); + verify(item, times(4)).isTracker(any()); + assertNull(player.nextMessage()); + // A tracker moved into the off hand is seen on the very next pulse. + player.getInventory().setItemInOffHand(new ItemStack(Material.COMPASS)); + tracker.pulse(); + verify(item, times(6)).isTracker(any()); + assertEquals("Archaeological signal detected.", player.nextMessage()); player.nextMessage(); + // Same type, different data: matched again, and still a tracker. + clearInvocations(item); + ItemStack renamed = new ItemStack(Material.COMPASS); + org.bukkit.inventory.meta.ItemMeta meta = renamed.getItemMeta(); meta.setDisplayName("Old compass"); renamed.setItemMeta(meta); + player.getInventory().setItemInOffHand(renamed); + tracker.pulse(); + verify(item, times(2)).isTracker(any()); + // Putting it away is matched on the next pulse; the pulse after reuses that answer. + clearInvocations(item); + player.getInventory().setItemInOffHand(null); + tracker.pulse(); tracker.pulse(); + verify(item, times(2)).isTracker(any()); + // A rebound pack-plugin lookup or a reload matches again on the next pulse. + clearInvocations(item); + tracker.itemChanged(); tracker.pulse(); + verify(item, times(2)).isTracker(any()); + tracker.setSettings(settings(true, false)); tracker.pulse(); + verify(item, times(4)).isTracker(any()); + tracker.stop(); tracker.pulse(); + verify(item, times(6)).isTracker(any()); + } + private List chimes() { var pitches = org.mockito.ArgumentCaptor.forClass(Float.class); verify(world, atLeast(0)).playSound(any(Location.class), eq(org.bukkit.Sound.BLOCK_NOTE_BLOCK_CHIME), eq(org.bukkit.SoundCategory.PLAYERS), anyFloat(), pitches.capture());