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