diff --git a/README.md b/README.md index dcf4bd5..c2e40cd 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Players encounter its effects through the plugins that depend on it: custom equi - **Equipment appearance preservation** — carries supported skin and appearance information through item rebuilding. - **Tiered socket handling** — shared rules for socket categories and the gems or runes applied to them. - **Equipment events** — exposes armour equip changes so other plugins can respond consistently. +- **Inventory scanning** — shared periodic and event-driven scans; gameplay plugins register handlers for their own item updates. - **Shared persistence and utilities** — provides database support and common text, time, and location helpers for dependent plugins. TLibs supports the gameplay plugins that provide the player-facing experiences, keeping common behavior in one place. diff --git a/pom.xml b/pom.xml index 1d76c93..e353da0 100644 --- a/pom.xml +++ b/pom.xml @@ -97,6 +97,12 @@ 5.10.2 test + + org.mockito + mockito-core + 5.20.0 + test + org.slf4j slf4j-simple @@ -208,9 +214,11 @@ maven-surefire-plugin 3.2.5 + + + org.slf4j.simple.SimpleServiceProvider + - io.papermc.paper:paper-api - net.md-5:bungeecord-chat local:ItemsAdder local:MMOItems local:MythicLib diff --git a/src/main/java/net/tfminecraft/tlibs/TLibs.java b/src/main/java/net/tfminecraft/tlibs/TLibs.java index 543e9ce..d03da7b 100644 --- a/src/main/java/net/tfminecraft/tlibs/TLibs.java +++ b/src/main/java/net/tfminecraft/tlibs/TLibs.java @@ -8,6 +8,7 @@ import net.tfminecraft.tlibs.config.RebuildConfig; import net.tfminecraft.tlibs.config.SocketTierConfig; import net.tfminecraft.tlibs.enums.APIType; +import net.tfminecraft.tlibs.itemscan.ItemScanService; import net.tfminecraft.tlibs.listener.FurnitureRepairListener; import net.tfminecraft.tlibs.mmoitem.GemApplyPrimer; import net.tfminecraft.tlibs.mmoitem.MMOItemRebuildRegistrar; @@ -33,6 +34,7 @@ public void onEnable() { saveDefaultConfig(); reloadPluginConfig(); initializeAPIs(); + ItemScanService.start(this); GemApplyPrimer.init(this); ArmorEquipEvent.registerListener(this); Bukkit.getPluginManager().registerEvents(new FurnitureRepairListener(), this); @@ -48,6 +50,13 @@ public void onEnable() { Bukkit.getLogger().info("[TLibs] Complete!"); } + @Override + public void onDisable() { + if (ItemScanService.get() != null) { + ItemScanService.get().stop(); + } + } + private void registerRebuildBridge() { getServer().getPluginManager().registerEvents(rebuildRegistrar, this); rebuildRegistrar.tryRegister(); diff --git a/src/main/java/net/tfminecraft/tlibs/itemscan/ItemScanHandler.java b/src/main/java/net/tfminecraft/tlibs/itemscan/ItemScanHandler.java new file mode 100644 index 0000000..c77a6be --- /dev/null +++ b/src/main/java/net/tfminecraft/tlibs/itemscan/ItemScanHandler.java @@ -0,0 +1,12 @@ +package net.tfminecraft.tlibs.itemscan; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +public interface ItemScanHandler { + + boolean matches(ItemStack stack); + + void update(Player player, Inventory inventory, int slot, ItemStack stack); +} diff --git a/src/main/java/net/tfminecraft/tlibs/itemscan/ItemScanService.java b/src/main/java/net/tfminecraft/tlibs/itemscan/ItemScanService.java new file mode 100644 index 0000000..0eb0362 --- /dev/null +++ b/src/main/java/net/tfminecraft/tlibs/itemscan/ItemScanService.java @@ -0,0 +1,155 @@ +package net.tfminecraft.tlibs.itemscan; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.block.BlockState; +import org.bukkit.block.DoubleChest; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +public final class ItemScanService implements Listener { + + private static ItemScanService instance; + + private final List handlers = new CopyOnWriteArrayList<>(); + private int roundRobin; + private BukkitTask task; + + public static ItemScanService get() { + return instance; + } + + public static void start(JavaPlugin plugin) { + if (instance != null) { + instance.stop(); + } + instance = new ItemScanService(); + plugin.getServer().getPluginManager().registerEvents(instance, plugin); + instance.task = Bukkit.getScheduler().runTaskTimer(plugin, instance::pulse, 0L, 2L); + } + + public void stop() { + if (task != null) { + task.cancel(); + task = null; + } + HandlerList.unregisterAll(this); + handlers.clear(); + if (instance == this) { + instance = null; + } + } + + public void subscribe(ItemScanHandler handler) { + if (handler != null && !handlers.contains(handler)) { + handlers.add(handler); + } + } + + public void unsubscribe(ItemScanHandler handler) { + handlers.remove(handler); + } + + private void pulse() { + if (handlers.isEmpty()) { + return; + } + List players = new ArrayList<>(Bukkit.getOnlinePlayers()); + if (players.isEmpty()) { + roundRobin = 0; + return; + } + if (roundRobin >= players.size()) { + roundRobin = 0; + } + Player player = players.get(roundRobin); + roundRobin++; + scanPlayerView(player); + } + + @EventHandler + public void onInventoryOpen(InventoryOpenEvent event) { + if (!(event.getPlayer() instanceof Player player)) { + return; + } + scanOpenView(player, event.getView()); + } + + @EventHandler + public void onPickup(EntityPickupItemEvent event) { + if (!(event.getEntity() instanceof Player player)) { + return; + } + Item entity = event.getItem(); + ItemStack stack = entity.getItemStack(); + applyHandlers(player, null, -1, stack); + entity.setItemStack(stack); + } + + private void scanPlayerView(Player player) { + InventoryView view = player.getOpenInventory(); + InventoryType topType = view.getTopInventory().getType(); + if (topType == InventoryType.CRAFTING || topType == InventoryType.CREATIVE) { + scanInventory(player, player.getInventory()); + return; + } + scanOpenView(player, view); + } + + private void scanOpenView(Player player, InventoryView view) { + scanInventory(player, view.getTopInventory()); + scanInventory(player, player.getInventory()); + } + + private void scanInventory(Player player, Inventory inventory) { + if (inventory == null || !isScannable(inventory)) { + return; + } + int size = inventory.getSize(); + for (int slot = 0; slot < size; slot++) { + scanSlot(player, inventory, slot); + } + } + + private static boolean isScannable(Inventory inventory) { + InventoryHolder holder = inventory.getHolder(); + if (holder == null) { + return true; + } + return holder instanceof Player || holder instanceof BlockState + || holder instanceof Entity || holder instanceof DoubleChest; + } + + private void scanSlot(Player player, Inventory inventory, int slot) { + ItemStack stack = inventory.getItem(slot); + applyHandlers(player, inventory, slot, stack); + } + + private void applyHandlers(Player player, Inventory inventory, int slot, ItemStack stack) { + if (stack == null || stack.getType() == Material.AIR || handlers.isEmpty()) { + return; + } + for (ItemScanHandler handler : handlers) { + if (handler.matches(stack)) { + handler.update(player, inventory, slot, stack); + } + } + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b205e52..21d8be0 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,7 +3,6 @@ api-version: '1.21.10' name: TLibs version: ${project.version} author: Drefvelin -folia-supported: true loadbefore: [RPCharacters] softdepend: [MMOItems, MythicLib, ItemsAdder, MythicMobs, MMOCore] diff --git a/src/test/java/net/tfminecraft/tlibs/itemscan/ItemScanServiceTest.java b/src/test/java/net/tfminecraft/tlibs/itemscan/ItemScanServiceTest.java new file mode 100644 index 0000000..c7fc7dc --- /dev/null +++ b/src/test/java/net/tfminecraft/tlibs/itemscan/ItemScanServiceTest.java @@ -0,0 +1,229 @@ +package net.tfminecraft.tlibs.itemscan; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Registry; +import io.papermc.paper.registry.RegistryAccess; +import org.bukkit.Server; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +class ItemScanServiceTest { + private MockedStatic bukkit; + private MockedStatic handlerLists; + private JavaPlugin plugin; + private PluginManager pluginManager; + private BukkitScheduler scheduler; + private BukkitTask task; + private ItemScanService service; + private Runnable pulse; + + @BeforeAll + static void initializeInventoryTypes() { + // Paper initializes menu registries when InventoryType loads. These tests + // exercise traversal only, so menu contents are deliberately unused. + try (MockedStatic registries = mockStatic(RegistryAccess.class)) { + RegistryAccess access = mock(RegistryAccess.class, invocation -> mock(Registry.class)); + registries.when(RegistryAccess::registryAccess).thenReturn(access); + InventoryType.values(); + } + } + + @BeforeEach + void start() { + bukkit = mockStatic(Bukkit.class); + handlerLists = mockStatic(HandlerList.class); + plugin = mock(JavaPlugin.class); + Server server = mock(Server.class); + pluginManager = mock(PluginManager.class); + scheduler = mock(BukkitScheduler.class); + task = mock(BukkitTask.class); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(pluginManager); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(2L))).thenReturn(task); + ItemScanService.start(plugin); + service = ItemScanService.get(); + ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskTimer(eq(plugin), callback.capture(), eq(0L), eq(2L)); + pulse = callback.getValue(); + } + + @AfterEach + void stop() { + if (ItemScanService.get() != null) ItemScanService.get().stop(); + handlerLists.close(); + bukkit.close(); + } + + @Test + void restartCancelsOldTaskUnregistersListenerAndDiscardsSubscriptions() { + verify(pluginManager).registerEvents(service, plugin); + ItemScanHandler handler = mock(ItemScanHandler.class); + service.subscribe(handler); + ItemScanService.start(plugin); + assertNotSame(service, ItemScanService.get()); + verify(task).cancel(); + handlerLists.verify(() -> HandlerList.unregisterAll(service)); + pulse.run(); + verifyNoInteractions(handler); + ItemScanService.get().stop(); + assertNull(ItemScanService.get()); + service.stop(); // Stopping an old instance is harmless. + } + + @Test + void pulsesVisitOnePlayerAtATimeAndSkipPersonalCraftingGrid() { + Player first = playerWithPersonalView(InventoryType.CRAFTING); + Player second = playerWithPersonalView(InventoryType.CREATIVE); + ItemStack firstItem = item(Material.STONE); + ItemStack secondItem = item(Material.DIRT); + when(first.getInventory().getSize()).thenReturn(1); + when(first.getInventory().getItem(0)).thenReturn(firstItem); + when(second.getInventory().getSize()).thenReturn(1); + when(second.getInventory().getItem(0)).thenReturn(secondItem); + ItemScanHandler handler = matchingHandler(); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(first, second)); + pulse.run(); + verify(handler).update(first, first.getInventory(), 0, firstItem); + verify(handler, never()).update(eq(second), any(), anyInt(), any()); + pulse.run(); + verify(handler).update(second, second.getInventory(), 0, secondItem); + pulse.run(); + verify(handler, times(2)).update(first, first.getInventory(), 0, firstItem); + verify(first.getOpenInventory().getTopInventory(), never()).getItem(anyInt()); + verify(second.getOpenInventory().getTopInventory(), never()).getItem(anyInt()); + } + + @Test + void openScansTopThenPlayerSlotsInRegistrationOrderAndIgnoresEmptySlots() { + Player player = playerWithPersonalView(InventoryType.CHEST); + Inventory top = player.getOpenInventory().getTopInventory(); + ItemStack stone = item(Material.STONE); + ItemStack dirt = item(Material.DIRT); + when(top.getSize()).thenReturn(3); + when(top.getItem(0)).thenReturn(stone); + ItemStack air = item(Material.AIR); + when(top.getItem(1)).thenReturn(air); + when(player.getInventory().getSize()).thenReturn(1); + when(player.getInventory().getItem(0)).thenReturn(dirt); + ItemScanHandler first = matchingHandler(); + ItemScanHandler second = matchingHandler(); + service.subscribe(first); // Duplicate subscription must not duplicate callbacks. + service.subscribe(null); + service.onInventoryOpen(new InventoryOpenEvent(player.getOpenInventory())); + var order = inOrder(first, second); + order.verify(first).matches(stone); + order.verify(first).update(player, top, 0, stone); + order.verify(second).matches(stone); + order.verify(second).update(player, top, 0, stone); + order.verify(first).matches(dirt); + order.verify(first).update(player, player.getInventory(), 0, dirt); + order.verify(second).matches(dirt); + order.verify(second).update(player, player.getInventory(), 0, dirt); + order.verifyNoMoreInteractions(); + clearInvocations(first, second); + service.unsubscribe(first); + service.onInventoryOpen(new InventoryOpenEvent(player.getOpenInventory())); + verifyNoInteractions(first); + verify(second).update(player, top, 0, stone); + } + + @Test + void pluginOwnedMenusAreSkippedWhilePlayerInventoryIsScanned() { + Player player = playerWithPersonalView(InventoryType.CHEST); + Inventory top = player.getOpenInventory().getTopInventory(); + when(top.getHolder()).thenReturn(mock(InventoryHolder.class)); + when(top.getSize()).thenReturn(1); + ItemStack stack = item(Material.STONE); + when(top.getItem(0)).thenReturn(stack); + when(player.getInventory().getSize()).thenReturn(1); + when(player.getInventory().getItem(0)).thenReturn(stack); + ItemScanHandler handler = matchingHandler(); + service.onInventoryOpen(new InventoryOpenEvent(player.getOpenInventory())); + verify(top, never()).getItem(anyInt()); + verify(handler).update(player, player.getInventory(), 0, stack); + } + + @Test + void pickupUsesDetachedSlotAndWritesMutatedStackBackEvenWhenEventCancelled() { + Player player = mock(Player.class); + Item entity = mock(Item.class); + ItemStack stack = item(Material.STONE); + when(entity.getItemStack()).thenReturn(stack); + ItemScanHandler handler = matchingHandler(); + doAnswer(invocation -> { stack.setAmount(2); return null; }) + .when(handler).update(player, null, -1, stack); + EntityPickupItemEvent event = new EntityPickupItemEvent(player, entity, 0); + event.setCancelled(true); // Preserve the original listener's cancellation semantics. + service.onPickup(event); + verify(handler).update(player, null, -1, stack); + verify(stack).setAmount(2); + verify(entity).setItemStack(stack); + } + + @Test + void nonMatchingHandlersDoNotUpdateItems() { + Player player = mock(Player.class); + Item entity = mock(Item.class); + ItemStack stack = item(Material.STONE); + when(entity.getItemStack()).thenReturn(stack); + ItemScanHandler handler = mock(ItemScanHandler.class); + service.subscribe(handler); + service.onPickup(new EntityPickupItemEvent(player, entity, 0)); + verify(handler).matches(stack); + verify(handler, never()).update(any(), any(), anyInt(), any()); + } + + private ItemScanHandler matchingHandler() { + ItemScanHandler handler = mock(ItemScanHandler.class); + when(handler.matches(any())).thenReturn(true); + service.subscribe(handler); + return handler; + } + + private Player playerWithPersonalView(InventoryType type) { + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + InventoryView view = mock(InventoryView.class); + Inventory top = mock(Inventory.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getHolder()).thenReturn(player); + when(player.getOpenInventory()).thenReturn(view); + when(view.getPlayer()).thenReturn(player); + when(view.getTopInventory()).thenReturn(top); + when(top.getType()).thenReturn(type); + return player; + } + + private ItemStack item(Material material) { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(material); + return item; + } +}