From 2ff7403b4f4c3b24953e9994ba8cb7342d6c0336 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:21:45 -0600 Subject: [PATCH 01/23] Clean up GUI session state --- .../api/inventory/GUISession.java | 207 ++++++++---------- 1 file changed, 95 insertions(+), 112 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/GUISession.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/GUISession.java index 9bd8033218..ea81d3e430 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/GUISession.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/GUISession.java @@ -1,112 +1,95 @@ -package com.bencodez.advancedcore.api.inventory; - -import org.bukkit.entity.Player; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; -import org.bukkit.inventory.InventoryView; - -import com.bencodez.simpleapi.player.PlayerUtils; - -/** - * Session holder for managing GUI state across inventory interactions. - */ -public class GUISession implements InventoryHolder { - /** - * Get the GUISession for a given inventory, or null if none exists for this - * inventory - * - * @param inventory The inventory to get the GUISession from - * @return The GUISession or null if none exists - */ - public static GUISession extractSession(Inventory inventory) { - if (inventory == null) { - return null; - } - InventoryHolder ih = inventory.getHolder(); - if (ih != null && ih instanceof GUISession) { - return (GUISession) ih; - } - return null; - } - - /** - * Extract the GUISession from the inventory currently being viewed by a player, - * or null if none exists - * - * @param player The player who's open inventory to extract the GUISession from - * @return The GUISession or null if none exists - */ - public static GUISession extractSession(Player player) { - if (player == null) { - return null; - } - InventoryView oInv = player.getOpenInventory(); - if (oInv == null) { - return null; - } - Inventory inv = PlayerUtils.getTopInventory(player); - if (inv != null) { - return extractSession(inv); - } - return extractSession(oInv.getTopInventory()); - - } - - private BInventory inventoryGUI; // GUI Being viewed - private int page = 1; // Currently displayed page number - - /** - * Construct a new GUISession - * - * @param inventoryGUI The inventory that this a session to view - * @param page The page currently being viewed - */ - public GUISession(BInventory inventoryGUI, int page) { - if (inventoryGUI == null) { - throw new IllegalArgumentException("InventoryGUI must not be null"); - } - - this.inventoryGUI = inventoryGUI; - this.page = page; - } - - /** - * Method inherited from Bukkit's InventoryHolder. Will always return null - * - * @return Null - */ - @Override - public Inventory getInventory() { // Part of InventoryHolder from bukkit - return null; // doesn't matter at all if null returned - } - - /** - * Get the InventoryGUI being viewed - * - * @return The InventoryGUI being viewed - */ - public BInventory getInventoryGUI() { - return inventoryGUI; - } - - /** - * Get the page currently being viewed - * - * @return The page - */ - public int getPage() { - return page; - } - - /** - * Set the page currently being viewed - * - * @param page The page - */ - public void setPage(int page) { - if (page < 1) { - throw new IllegalArgumentException("Page must be >= 1"); - } - this.page = page; - } -} +package com.bencodez.advancedcore.api.inventory; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; + +import com.bencodez.simpleapi.player.PlayerUtils; + +/** + * Session holder for managing GUI state across inventory interactions. + */ +public class GUISession implements InventoryHolder { + + /** + * Get the GUISession for a given inventory, or null if none exists for this + * inventory. + * + * @param inventory the inventory to inspect + * @return the GUISession or null if none exists + */ + public static GUISession extractSession(Inventory inventory) { + if (inventory == null) { + return null; + } + InventoryHolder holder = inventory.getHolder(); + return holder instanceof GUISession ? (GUISession) holder : null; + } + + /** + * Extract the GUISession from the inventory currently being viewed by a player. + * + * @param player the player whose open inventory should be inspected + * @return the GUISession or null if none exists + */ + public static GUISession extractSession(Player player) { + if (player == null) { + return null; + } + + InventoryView openInventory = player.getOpenInventory(); + if (openInventory == null) { + return null; + } + + Inventory topInventory = PlayerUtils.getTopInventory(player); + if (topInventory == null) { + topInventory = openInventory.getTopInventory(); + } + return extractSession(topInventory); + } + + private final BInventory inventoryGUI; + private int page = 1; + + /** + * Construct a new GUISession. + * + * @param inventoryGUI the GUI represented by this session + * @param page the page currently being viewed + */ + public GUISession(BInventory inventoryGUI, int page) { + if (inventoryGUI == null) { + throw new IllegalArgumentException("InventoryGUI must not be null"); + } + this.inventoryGUI = inventoryGUI; + setPage(page); + } + + /** + * InventoryHolder implementation. The concrete inventory owns this holder, so + * no secondary inventory needs to be returned here. + * + * @return null + */ + @Override + public Inventory getInventory() { + return null; + } + + public BInventory getInventoryGUI() { + return inventoryGUI; + } + + public int getPage() { + return page; + } + + public void setPage(int page) { + if (page < 1) { + throw new IllegalArgumentException("Page must be >= 1"); + } + this.page = page; + } +} From 7788ea80f721614615bf89fc827325e86989892c Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:22:00 -0600 Subject: [PATCH 02/23] Keep inventory click handling on Bukkit thread --- .../api/inventory/BInventoryListener.java | 335 ++++++++---------- 1 file changed, 153 insertions(+), 182 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java index 6e6dc705c5..779e61005c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java @@ -1,182 +1,153 @@ -package com.bencodez.advancedcore.api.inventory; - -import org.bukkit.Material; -import org.bukkit.entity.Player; -import org.bukkit.event.Event.Result; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryType; -import org.bukkit.inventory.ItemStack; - -import com.bencodez.advancedcore.AdvancedCorePlugin; -import com.bencodez.simpleapi.messages.MessageAPI; - -/** - * Listener for inventory interactions. - */ -public class BInventoryListener implements Listener { - private AdvancedCorePlugin plugin; - - /** - * Instantiates a new BInventory listener. - * - * @param plugin the plugin instance - */ - public BInventoryListener(AdvancedCorePlugin plugin) { - this.plugin = plugin; - } - - /** - * Handle inventory click events. - * - * @param event the inventory click event - */ - @EventHandler(priority = EventPriority.LOWEST) - public void onInventoryClick(InventoryClickEvent event) { - if (!(event.getWhoClicked() instanceof Player)) { - return; - } - - final Player player = (Player) event.getWhoClicked(); - - plugin.getBukkitScheduler().runTaskAsynchronously(plugin, new Runnable() { - - @Override - public void run() { - if (player.getInventory().firstEmpty() != -1) { - plugin.getFullInventoryHandler().check(player); - } - } - }); - - final GUISession session = GUISession.extractSession(player); - if (session == null) { - return; - } - final BInventory gui = session.getInventoryGUI(); - event.setCancelled(true); - event.setResult(Result.DENY); - if (event.getClickedInventory() != null && event.getClickedInventory().getType() == InventoryType.CHEST) { - - if (event.isShiftClick() && event.getClickedInventory() != null - && event.getRawSlot() < event.getInventory().getSize()) { - event.setCurrentItem(new ItemStack(Material.AIR)); - } - player.setItemOnCursor(new ItemStack(Material.AIR)); - player.updateInventory(); - - if (plugin.getOptions().isCloseGUIOnShiftClick()) { - gui.forceClose(player); - } - if (gui.isCloseInv()) { - gui.closeInv(player, null); - } - - // prevent spam clicking, to avoid dupe issues on large servers - long cTime = System.currentTimeMillis(); - if (cTime - gui.getLastPressTime() < plugin.getOptions().getSpamClickTimeMs()) { - plugin.debug(player.getName() + " spam clicking GUI, preventing exploits"); - player.updateInventory(); - event.setCurrentItem(new ItemStack(Material.AIR)); - gui.forceClose(player); - - // spam click message - String msg = plugin.getOptions().getSpamClickMessage(); - if (!msg.isEmpty()) { - player.sendMessage(MessageAPI.colorize(msg)); - } - - return; - } - - gui.setLastPressTime(cTime); - - plugin.getBukkitScheduler().runTaskAsynchronously(plugin, new Runnable() { - - @Override - public void run() { - int slot = event.getSlot(); - if (!gui.isPages()) { - for (int buttonSlot : gui.getButtons().keySet()) { - BInventoryButton button = gui.getButtons().get(buttonSlot); - if (slot == buttonSlot) { - - gui.closeInv(player, button); - - try { - gui.onClick(event, button); - } catch (Exception e) { - e.printStackTrace(); - } - - } - - } - } else { - final int maxInvSize = gui.getMaxInvSize(); - final int page = session.getPage(); - final int maxPage = gui.getMaxPage(); - - if (slot < maxInvSize - 9) { - int buttonSlot = (page - 1) * (maxInvSize - 9) + event.getSlot(); - BInventoryButton button = gui.getButtons().get(buttonSlot); - if (button != null) { - gui.closeInv(player, button); - - try { - gui.onClick(event, button); - } catch (Exception e) { - e.printStackTrace(); - } - - return; - } - - } else if (slot == maxInvSize - 9) { - if (page > 1) { - - final int nextPage = page - 1; - - // gui.forceClose(player); - gui.playSound(player); - gui.openInventory(player, nextPage); - - } - } else if (slot == maxInvSize - 1) { - // AdvancedCorePlugin.getInstance().debug(maxPage + " " + - // page); - if (maxPage > page) { - - final int nextPage = page + 1; - - gui.playSound(player); - // gui.forceClose(player); - gui.openInventory(player, nextPage); - - } - - } - - for (BInventoryButton b : gui.getPageButtons()) { - if (slot == b.getSlot() + (gui.getMaxInvSize() - 9)) { - gui.closeInv(player, b); - - try { - gui.onClick(event, b); - } catch (Exception e) { - e.printStackTrace(); - } - return; - - } - - } - } - } - }); - } - - } -} +package com.bencodez.advancedcore.api.inventory; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.Event.Result; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.ItemStack; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.simpleapi.messages.MessageAPI; + +/** + * Listener for inventory interactions. + */ +public class BInventoryListener implements Listener { + private final AdvancedCorePlugin plugin; + + public BInventoryListener(AdvancedCorePlugin plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onInventoryClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player)) { + return; + } + + Player player = (Player) event.getWhoClicked(); + scheduleFullInventoryCheck(player); + + GUISession session = GUISession.extractSession(player); + if (session == null) { + return; + } + + BInventory gui = session.getInventoryGUI(); + event.setCancelled(true); + event.setResult(Result.DENY); + + if (event.getClickedInventory() == null || event.getClickedInventory().getType() != InventoryType.CHEST) { + return; + } + + prepareClick(event, player, gui); + if (isSpamClick(player, gui, event)) { + return; + } + + gui.setLastPressTime(System.currentTimeMillis()); + if (gui.isPages()) { + handlePagedClick(event, player, gui, session); + } else { + handleButtonClick(event, player, gui, gui.getButtons().get(event.getSlot())); + } + } + + private void scheduleFullInventoryCheck(Player player) { + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (player.getInventory().firstEmpty() != -1) { + plugin.getFullInventoryHandler().check(player); + } + }, player); + } + + private void prepareClick(InventoryClickEvent event, Player player, BInventory gui) { + if (event.isShiftClick() && event.getRawSlot() < event.getInventory().getSize()) { + event.setCurrentItem(new ItemStack(Material.AIR)); + } + player.setItemOnCursor(new ItemStack(Material.AIR)); + player.updateInventory(); + + if (plugin.getOptions().isCloseGUIOnShiftClick()) { + gui.forceClose(player); + } + if (gui.isCloseInv()) { + gui.closeInv(player, null); + } + } + + private boolean isSpamClick(Player player, BInventory gui, InventoryClickEvent event) { + long currentTime = System.currentTimeMillis(); + if (currentTime - gui.getLastPressTime() >= plugin.getOptions().getSpamClickTimeMs()) { + return false; + } + + plugin.debug(player.getName() + " spam clicking GUI, preventing exploits"); + player.updateInventory(); + event.setCurrentItem(new ItemStack(Material.AIR)); + gui.forceClose(player); + + String message = plugin.getOptions().getSpamClickMessage(); + if (!message.isEmpty()) { + player.sendMessage(MessageAPI.colorize(message)); + } + return true; + } + + private void handlePagedClick(InventoryClickEvent event, Player player, BInventory gui, GUISession session) { + int slot = event.getSlot(); + int maxInventorySize = gui.getMaxInvSize(); + int contentSize = maxInventorySize - 9; + int page = session.getPage(); + + if (slot < contentSize) { + int buttonSlot = (page - 1) * contentSize + slot; + BInventoryButton button = gui.getButtons().get(buttonSlot); + if (button != null) { + handleButtonClick(event, player, gui, button); + } + return; + } + + if (slot == contentSize) { + if (page > 1) { + gui.playSound(player); + gui.openInventory(player, page - 1); + } + return; + } + + if (slot == maxInventorySize - 1) { + if (page < gui.getMaxPage()) { + gui.playSound(player); + gui.openInventory(player, page + 1); + } + return; + } + + for (BInventoryButton button : gui.getPageButtons()) { + if (slot == button.getSlot() + contentSize) { + handleButtonClick(event, player, gui, button); + return; + } + } + } + + private void handleButtonClick(InventoryClickEvent event, Player player, BInventory gui, BInventoryButton button) { + if (button == null) { + return; + } + + gui.closeInv(player, button); + try { + gui.onClick(event, button); + } catch (Exception exception) { + plugin.debug(exception); + } + } +} From 82dff7bedb759f8352b5a24aa217afbdbb76cbb1 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:22:24 -0600 Subject: [PATCH 03/23] Consolidate updating inventory button lifecycle --- .../inventory/UpdatingBInventoryButton.java | 315 +++++++++--------- 1 file changed, 159 insertions(+), 156 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java index 71ef7bcb46..ae52d2a0f9 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java @@ -1,156 +1,159 @@ -package com.bencodez.advancedcore.api.inventory; - -import java.util.concurrent.TimeUnit; - -import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; - -import com.bencodez.advancedcore.AdvancedCorePlugin; -import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent; -import com.bencodez.advancedcore.api.item.ItemBuilder; -import com.bencodez.simpleapi.player.PlayerUtils; - -import lombok.Getter; - -public abstract class UpdatingBInventoryButton extends BInventoryButton { - @Getter - private long delay; - @Getter - private long updateInterval; - private AdvancedCorePlugin plugin; - @Getter - private boolean updateOnClick = false; - @Getter - private long clickUpdateDelay = 0; - - public UpdatingBInventoryButton(AdvancedCorePlugin plugin, ItemBuilder item, long delay, long updateInterval) { - super(item); - this.plugin = plugin; - this.updateInterval = updateInterval; - this.delay = delay; - } - - public UpdatingBInventoryButton(AdvancedCorePlugin plugin, ItemStack item, long delay, long updateInterval) { - super(item); - this.plugin = plugin; - this.updateInterval = updateInterval; - this.delay = delay; - } - - public UpdatingBInventoryButton(AdvancedCorePlugin plugin, String name, String[] lore, ItemStack item, long delay, - long updateInterval) { - super(name, lore, item); - this.plugin = plugin; - this.updateInterval = updateInterval; - this.delay = delay; - } - - private void checkUpdate(Player p) { - if (!plugin.isLoadUserData() || plugin.getUserManager().getDataManager().isCached(p.getUniqueId())) { - final ItemStack item = onUpdate(p).toItemStack(p); - if (item != null) { - if (plugin.isEnabled()) { - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - try { - if (p != null && getInv().isOpen(p)) { - if (getFillSlots() != null && getFillSlots().size() > 0) { - for (Integer slot : getFillSlots()) { - PlayerUtils.getTopInventory(p).setItem(slot.intValue(), item); - } - } else { - PlayerUtils.getTopInventory(p).setItem(getSlot(), item); - } - } else { - getInv().cancelTimer(); - } - } catch (Exception e) { - plugin.debug(e); - getInv().cancelTimer(); - } - - } - }, p); - } else { - getInv().cancelTimer(); - } - } else { - getInv().cancelTimer(); - } - } - } - - public UpdatingBInventoryButton delay(long mileseconds) { - this.clickUpdateDelay = mileseconds; - return this; - } - - @Override - public void load(Player p) { - getInv().addUpdatingButton(plugin, delay, updateInterval, new Runnable() { - - @Override - public void run() { - checkUpdate(p); - } - }); - } - - @Override - public void onClick(ClickEvent event, BInventory inv) { - super.onClick(event, inv); - if (updateOnClick) { - if (clickUpdateDelay > 0) { - plugin.getInventoryTimer().schedule(new Runnable() { - - @Override - public void run() { - update(event.getPlayer()); - } - }, clickUpdateDelay, TimeUnit.MILLISECONDS); - } else { - update(event.getPlayer()); - } - } - } - - public abstract ItemBuilder onUpdate(Player player); - - public void update(Player p) { - if (!plugin.isLoadUserData() || plugin.getUserManager().getDataManager().isCached(p.getUniqueId())) { - final ItemStack item = onUpdate(p).toItemStack(p); - if (item != null) { - if (plugin.isEnabled()) { - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - try { - if (p != null && getInv().isOpen(p)) { - if (getFillSlots() != null && getFillSlots().size() > 0) { - for (Integer slot : getFillSlots()) { - p.getOpenInventory().getTopInventory().setItem(slot.intValue(), item); - } - } else { - p.getOpenInventory().getTopInventory().setItem(getSlot(), item); - } - } - } catch (Exception e) { - plugin.debug(e); - } - - } - }, p); - } - } - } - } - - public UpdatingBInventoryButton updateOnClick() { - updateOnClick = true; - return this; - } - -} +package com.bencodez.advancedcore.api.inventory; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent; +import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.simpleapi.player.PlayerUtils; + +import lombok.Getter; + +public abstract class UpdatingBInventoryButton extends BInventoryButton { + @Getter + private final long delay; + @Getter + private final long updateInterval; + private final AdvancedCorePlugin plugin; + @Getter + private boolean updateOnClick = false; + @Getter + private long clickUpdateDelay = 0; + + public UpdatingBInventoryButton(AdvancedCorePlugin plugin, ItemBuilder item, long delay, long updateInterval) { + super(item); + this.plugin = plugin; + this.updateInterval = updateInterval; + this.delay = delay; + } + + public UpdatingBInventoryButton(AdvancedCorePlugin plugin, ItemStack item, long delay, long updateInterval) { + super(item); + this.plugin = plugin; + this.updateInterval = updateInterval; + this.delay = delay; + } + + public UpdatingBInventoryButton(AdvancedCorePlugin plugin, String name, String[] lore, ItemStack item, long delay, + long updateInterval) { + super(name, lore, item); + this.plugin = plugin; + this.updateInterval = updateInterval; + this.delay = delay; + } + + public UpdatingBInventoryButton delay(long milliseconds) { + this.clickUpdateDelay = milliseconds; + return this; + } + + @Override + public void load(Player player) { + BInventory inventory = getInv(); + if (inventory == null) { + return; + } + inventory.addUpdatingButton(plugin, delay, updateInterval, () -> scheduleUpdate(player, true)); + } + + @Override + public void onClick(ClickEvent event, BInventory inventory) { + super.onClick(event, inventory); + if (!updateOnClick) { + return; + } + + if (clickUpdateDelay > 0) { + plugin.getInventoryTimer().schedule(() -> update(event.getPlayer()), clickUpdateDelay, TimeUnit.MILLISECONDS); + } else { + update(event.getPlayer()); + } + } + + public abstract ItemBuilder onUpdate(Player player); + + public void update(Player player) { + scheduleUpdate(player, false); + } + + public UpdatingBInventoryButton updateOnClick() { + updateOnClick = true; + return this; + } + + private void scheduleUpdate(Player player, boolean cancelWhenUnavailable) { + BInventory inventory = getInv(); + if (inventory == null) { + return; + } + if (!plugin.isEnabled()) { + if (cancelWhenUnavailable) { + inventory.cancelTimer(); + } + return; + } + + plugin.getBukkitScheduler().runTask(plugin, () -> applyUpdate(player, cancelWhenUnavailable), player); + } + + private void applyUpdate(Player player, boolean cancelWhenUnavailable) { + BInventory inventory = getInv(); + if (inventory == null || player == null || !plugin.isEnabled()) { + cancelIfRequested(inventory, cancelWhenUnavailable); + return; + } + + if (plugin.isLoadUserData() && !plugin.getUserManager().getDataManager().isCached(player.getUniqueId())) { + return; + } + if (!inventory.isOpen(player)) { + cancelIfRequested(inventory, cancelWhenUnavailable); + return; + } + + try { + ItemBuilder builder = onUpdate(player); + if (builder == null) { + cancelIfRequested(inventory, cancelWhenUnavailable); + return; + } + + ItemStack item = builder.toItemStack(player); + if (item == null) { + cancelIfRequested(inventory, cancelWhenUnavailable); + return; + } + + Inventory topInventory = PlayerUtils.getTopInventory(player); + if (topInventory == null) { + cancelIfRequested(inventory, cancelWhenUnavailable); + return; + } + + List fillSlots = getFillSlots(); + if (fillSlots != null && !fillSlots.isEmpty()) { + for (Integer slot : fillSlots) { + if (slot != null) { + topInventory.setItem(slot.intValue(), item); + } + } + } else { + topInventory.setItem(getSlot(), item); + } + } catch (Exception exception) { + plugin.debug(exception); + cancelIfRequested(inventory, cancelWhenUnavailable); + } + } + + private void cancelIfRequested(BInventory inventory, boolean cancelWhenUnavailable) { + if (cancelWhenUnavailable && inventory != null) { + inventory.cancelTimer(); + } + } +} From 22b51a43f4389a241bf0e6b39df59bd77db4944d Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:23:08 -0600 Subject: [PATCH 04/23] Centralize inventory pagination math --- .../api/inventory/InventoryPagination.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/InventoryPagination.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/InventoryPagination.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/InventoryPagination.java new file mode 100644 index 0000000000..e1bcf7cefd --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/InventoryPagination.java @@ -0,0 +1,32 @@ +package com.bencodez.advancedcore.api.inventory; + +/** + * Shared pagination calculations for {@link BInventory} and its listener. + */ +public final class InventoryPagination { + private static final int NAVIGATION_ROW_SIZE = 9; + + private InventoryPagination() { + } + + public static int getContentSize(int inventorySize) { + return Math.max(1, inventorySize - NAVIGATION_ROW_SIZE); + } + + public static int getPageCount(int highestSlot, int inventorySize) { + int contentSize = getContentSize(inventorySize); + int requiredSlots = Math.max(1, highestSlot + 1); + return Math.max(1, (requiredSlots + contentSize - 1) / contentSize); + } + + public static int getButtonSlot(int page, int displayedSlot, int inventorySize) { + if (page < 1) { + throw new IllegalArgumentException("Page must be >= 1"); + } + return (page - 1) * getContentSize(inventorySize) + displayedSlot; + } + + public static boolean isContentSlot(int slot, int inventorySize) { + return slot >= 0 && slot < getContentSize(inventorySize); + } +} From fbccf80091d3d680505dbdf1b1ca218ba3cd5084 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:24:27 -0600 Subject: [PATCH 05/23] Clean up inventory pagination and timer lifecycle --- .../api/inventory/BInventory.java | 1508 +++++++---------- 1 file changed, 594 insertions(+), 914 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java index fd7273e2af..df906108e0 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java @@ -1,914 +1,594 @@ -/* Obtained from https://www.spigotmc.org/threads/libish-inventory-api-kinda.49339/ - * and modified - */ - -package com.bencodez.advancedcore.api.inventory; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; -import java.util.Map.Entry; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.regex.Pattern; - -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.Sound; -import org.bukkit.entity.Player; -import org.bukkit.event.inventory.ClickType; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; - -import com.bencodez.advancedcore.AdvancedCorePlugin; -import com.bencodez.advancedcore.api.item.ItemBuilder; -import com.bencodez.advancedcore.api.messages.PlaceholderUtils; -import com.bencodez.simpleapi.messages.MessageAPI; -import com.bencodez.simpleapi.player.PlayerUtils; - -import lombok.Getter; -import lombok.Setter; - -/** - * The Class BInventory. - */ -public class BInventory { - - /** - * The Class ClickEvent. - */ - public class ClickEvent { - - /** - * The button that was clicked. - * - * @return the button - */ - @Getter - private BInventoryButton button; - - /** - * The click type. - * - * @return the click type - */ - @Getter - private ClickType click; - - /** - * The clicked item. - * - * @return the clicked item - */ - @Getter - private ItemStack clickedItem; - - /** - * The inventory click event. - * - * @return the inventory click event - */ - @Getter - private InventoryClickEvent event; - - /** - * The inventory. - * - * @return the inventory - */ - @Getter - private Inventory inventory; - - /** - * The player who clicked. - * - * @return the player - */ - @Getter - private Player player; - - /** - * The slot that was clicked. - * - * @return the slot number - */ - @Getter - private int slot; - - /** - * Instantiates a new click event. - * - * @param event the inventory click event - * @param b the button - */ - public ClickEvent(InventoryClickEvent event, BInventoryButton b) { - this.event = event; - player = (Player) event.getWhoClicked(); - click = event.getClick(); - inventory = event.getInventory(); - clickedItem = event.getCurrentItem(); - slot = event.getSlot(); - button = b; - } - - /** - * Close the inventory for the player. - */ - public void closeInventory() { - runSync(new Runnable() { - - @Override - public void run() { - if (player != null) { - player.closeInventory(); - } - } - }); - } - - /** - * Gets the current item. - * - * @return the current item - */ - public ItemStack getCurrentItem() { - return clickedItem; - } - - /** - * Gets the meta. - * - * @param player the player - * @param str the str - * @return the meta - */ - public Object getMeta(Player player, String str) { - return PlayerUtils.getPlayerMeta(AdvancedCorePlugin.getInstance(), player, str); - } - - /** - * Gets the meta. - * - * @param str the str - * @return the meta - */ - public Object getMeta(String str) { - return PlayerUtils.getPlayerMeta(AdvancedCorePlugin.getInstance(), player, str); - } - - /** - * Gets the who clicked. - * - * @return the who clicked - */ - public Player getWhoClicked() { - return player; - } - - /** - * Run a task synchronously. - * - * @param run the runnable to execute - */ - public void runSync(Runnable run) { - AdvancedCorePlugin.getInstance().getBukkitScheduler().runTask(AdvancedCorePlugin.getInstance(), run); - } - } - - /** - * Open inventory. - * - * @param player the player - * @param inventory the inventory - */ - public static void openInventory(Player player, BInventory inventory) { - inventory.openInventory(player); - } - - private Map buttons = new HashMap<>(); - - /** - * Whether the inventory should close after interaction. - * - * @return whether the inventory should close - */ - @Getter - private boolean closeInv = true; - - private HashMap data = new HashMap<>(); - - private Inventory inv; - - private String inventoryName; - - /** - * The last time a button was pressed in this inventory. - * - * @return the last press time in milliseconds - * @param lastPressTime the last press time to set - */ - @Getter - @Setter - private long lastPressTime = 0; - - private int maxInvSize = 54; - - /** - * The maximum page number for paginated inventories. - * - * @return the maximum page number - */ - @Getter - private int maxPage = 1; - - private ItemStack nextItem; - - /** - * The current page number for paginated inventories. - * - * @return the current page number - */ - @Getter - private int page = 1; - - private ArrayList pageButtons = new ArrayList<>(); - - private boolean pages = false; - - private String perm; - - /** - * Placeholders to be replaced in the inventory. - * - * @return the placeholder map - */ - @Getter - private HashMap placeholders = new HashMap<>(); - - /** - * Whether to play sound on inventory interaction. - * - * @return whether sound should play - * @param playerSound whether sound should play - */ - @Getter - @Setter - private boolean playerSound = true; - - private ItemStack prevItem; - - @SuppressWarnings("rawtypes") - ArrayList futures; - - private ArrayList fillItems = new ArrayList<>(); - - /** - * Instantiates a new b inventory. - * - * @param name the name - */ - public BInventory(String name) { - setInventoryName(name); - } - - /** - * Adds the button. - * - * Slot of -2 will add item to end of the GUI (last available slot) - * - * @param button the button - */ - public void addButton(BInventoryButton button) { - int slot = button.getSlot(); - if (slot == -1) { - slot = getNextSlot(); - } - if (slot == -2) { - slot = getProperSize(getNextSlot()) - 1; - } - if (!button.isFillEmptySlots()) { - if (button.getFillSlots() != null && button.getFillSlots().size() > 0) { - for (Integer fill : button.getFillSlots()) { - slot = fill.intValue(); - BInventoryButton button1 = new BInventoryButton(button) { - - @Override - public void onClick(ClickEvent clickEvent) { - button.onClick(clickEvent); - } - }; - button1.setSlot(slot); - getButtons().put(slot, button1); - } - } else { - // no fill slots set - button.setSlot(slot); - getButtons().put(slot, button); - } - } else { - fillItems.add(button); - // fill empty slots - - } - } - - /** - * Check if a slot is taken by a button. - * - * @param slot the slot to check - * @return true if the slot is taken - */ - public boolean isSlotTaken(int slot) { - return buttons.containsKey(slot); - } - - /** - * Adds the button. - * - * @param position the position - * @param button the button - */ - public void addButton(int position, BInventoryButton button) { - getButtons().put(position, button); - } - - /** - * Add data to this inventory. - * - * @param key the key - * @param object the value - * @return this inventory - */ - public BInventory addData(String key, Object object) { - getData().put(key, object); - return this; - } - - private void addFillSlots() { - for (BInventoryButton button : fillItems) { - for (int i = 0; i < getInventorySize(); i++) { - boolean slotExist = false; - for (Integer exist : getButtons().keySet()) { - if (exist.intValue() == i) { - slotExist = true; - } - } - if (!slotExist) { - BInventoryButton button1 = new BInventoryButton(button) { - - @Override - public void onClick(ClickEvent clickEvent) { - button.onClick(clickEvent); - } - }; - button1.setSlot(i); - getButtons().put(i, button1); - } - } - } - fillItems.clear(); - } - - /** - * Add a placeholder to this inventory. - * - * @param toReplace the string to replace - * @param replaceWith the replacement string - * @return this inventory - */ - public BInventory addPlaceholder(String toReplace, String replaceWith) { - placeholders.put(toReplace, replaceWith); - return this; - } - - /** - * Add an updating button with scheduled updates. - * - * @param plugin the plugin instance - * @param delay the initial delay - * @param interval the update interval - * @param runnable the runnable to execute - */ - public void addUpdatingButton(AdvancedCorePlugin plugin, long delay, long interval, Runnable runnable) { - if (futures == null) { - futures = new ArrayList<>(); - } - futures.add(plugin.getInventoryTimer().scheduleWithFixedDelay(runnable, delay, interval, TimeUnit.MILLISECONDS)); - } - - /** - * Cancel all scheduled timers for this inventory. - */ - @SuppressWarnings("rawtypes") - public void cancelTimer() { - if (futures != null) { - for (ScheduledFuture f : futures) { - f.cancel(true); - } - futures = null; - } - } - - /** - * Close the inventory for a player after button click. - * - * @param p the player - * @param b the button that was clicked - */ - public void closeInv(Player p, BInventoryButton b) { - if (!PlayerUtils.getTopInventory(p).equals(inv)) { - return; - } - - if (pages || (closeInv && (b == null || !b.isCloseInvSet()))) { - forceClose(p); - return; - } - - if (b != null && b.isCloseInvSet() && b.isCloseInv()) { - forceClose(p); - return; - } - } - - private void closeUpdatingBInv() { - cancelTimer(); - } - - /** - * Set this inventory to not close after interaction. - * - * @return this inventory - */ - public BInventory dontClose() { - closeInv = false; - return this; - } - - /** - * Force close the inventory for a player. - * - * @param p the player - */ - public void forceClose(Player p) { - if (Bukkit.isPrimaryThread()) { - p.closeInventory(); - - AdvancedCorePlugin.getInstance().getBukkitScheduler() - .runTaskAsynchronously(AdvancedCorePlugin.getInstance(), new Runnable() { - - @Override - public void run() { - closeUpdatingBInv(); - } - }); - } else { - closeUpdatingBInv(); - AdvancedCorePlugin.getInstance().getBukkitScheduler().runTask(AdvancedCorePlugin.getInstance(), - new Runnable() { - - @Override - public void run() { - p.closeInventory(); - } - }, p); - } - } - - /** - * Gets the buttons. - * - * @return the buttons - */ - public Map getButtons() { - return buttons; - } - - /** - * @return the data - */ - public HashMap getData() { - return data; - } - - /** - * Get data by key. - * - * @param key the key - * @return the data value - */ - public Object getData(String key) { - return data.get(key); - } - - /** - * Get data by key with default value. - * - * @param key the key - * @param defaultValue the default value if key is not found - * @return the data value or default value - */ - public Object getData(String key, Object defaultValue) { - if (data.containsKey(key)) { - return data.get(key); - } - return defaultValue; - } - - /** - * Get the first empty slot in the inventory. - * - * @return the first empty slot number - */ - public int getFirstEmptySlot() { - if (buttons.keySet().size() == 0) { - return 0; - } - - for (int i = 0; i < getInventorySize(); i++) { - if (!buttons.containsKey(i)) { - return i; - } - } - return getHighestSlot() + 1; - - } - - /** - * Gets the highest slot. - * - * @return the highest slot - */ - public int getHighestSlot() { - int highestNum = 0; - for (int num : buttons.keySet()) { - if (num > highestNum) { - highestNum = num; - } - } - return highestNum; - } - - /** - * Gets the inventory name. - * - * @return the inventory name - */ - public String getInventoryName() { - return inventoryName; - } - - /** - * Gets the inventory size. - * - * @return the inventory size - */ - public int getInventorySize() { - return getProperSize(getHighestSlot()); - } - - /** - * @return the maxInvSize - */ - public int getMaxInvSize() { - return maxInvSize; - } - - /** - * Get meta from player. - * - * @param player the player - * @param str the meta key - * @return the meta value - */ - public Object getMeta(Player player, String str) { - return PlayerUtils.getPlayerMeta(AdvancedCorePlugin.getInstance(), player, str); - } - - /** - * @return the nextItem - */ - public ItemStack getNextItem() { - return nextItem; - } - - /** - * Gets the next slot. - * - * @return the next slot - */ - public int getNextSlot() { - if (buttons.keySet().size() == 0) { - return 0; - } - return getHighestSlot() + 1; - } - - /** - * @return the pageButtons - */ - public ArrayList getPageButtons() { - return pageButtons; - } - - /** - * @return the prevItem - */ - public ItemStack getPrevItem() { - return prevItem; - } - - private int getProperSize(int size) { - if (size < 9) { - return 9; - } - if (size < 18) { - return 18; - } else if (size < 27) { - return 27; - } else if (size < 36) { - return 36; - } else if (size < 45) { - return 45; - } else { - return 54; - } - } - - /** - * Check if the inventory is open for a player. - * - * @param p the player - * @return true if the inventory is open - */ - public boolean isOpen(Player p) { - GUISession session = GUISession.extractSession(p); - if (session != null && session.getInventoryGUI() == this) { - return true; - } - return false; - } - - /** - * @return the pages - */ - public boolean isPages() { - return pages; - } - - /** - * Disable sound for this inventory. - * - * @return this inventory - */ - public BInventory noSound() { - playerSound = false; - return this; - } - - /** - * Handle button click. - * - * @param event the inventory click event - * @param b the button that was clicked - */ - public void onClick(InventoryClickEvent event, BInventoryButton b) { - playSound((Player) event.getWhoClicked()); - b.onClick(new ClickEvent(event, b), this); - } - - private void openInv(Player player, Inventory inv) { - AdvancedCorePlugin.getInstance().getBukkitScheduler().runTask(AdvancedCorePlugin.getInstance(), new Runnable() { - - @Override - public void run() { - player.openInventory(inv); - } - }, player); - } - - /** - * Open inventory. - * - * @param player the player - */ - @SuppressWarnings({}) - public void openInventory(Player player) { - if (player.isSleeping()) { - AdvancedCorePlugin.getInstance().debug(player.getName() + " is sleeping, not opening gui!"); - return; - } - if (perm != null) { - if (!perm.contains("|")) { - if (!player.hasPermission(perm)) { - player.sendMessage( - MessageAPI.colorize(AdvancedCorePlugin.getInstance().getOptions().getFormatNoPerms())); - return; - } - } else { - boolean hasPerm = false; - for (String permStr : perm.split(Pattern.quote("|"))) { - if (player.hasPermission(permStr)) { - hasPerm = true; - } - } - if (!hasPerm) { - player.sendMessage( - MessageAPI.colorize(AdvancedCorePlugin.getInstance().getOptions().getFormatNoPerms())); - return; - } - } - } - addFillSlots(); - BInventory inventory = this; - - if (inventory.getHighestSlot() >= maxInvSize) { - pages = true; - } - if (!pages) { - inv = Bukkit.createInventory(new GUISession(this, 1), inventory.getInventorySize(), - PlaceholderUtils.replaceJavascript(player, - PlaceholderUtils.replacePlaceHolder(inventory.getInventoryName(), getPlaceholders()))); - for (Entry pair : inventory.getButtons().entrySet()) { - ItemStack item = pair.getValue().getItem(player, getPlaceholders()); - inv.setItem(pair.getKey(), item); - - BInventoryButton b = pair.getValue(); - b.setInv(this); - b.setSlot(pair.getKey()); - b.load(player); - - } - - openInv(player, inv); - - } else { - maxPage = getHighestSlot() / (maxInvSize - 9); - if (getHighestSlot() % (maxInvSize - 9) != 0) { - maxPage++; - } - addPlaceholder("totalpages", "" + maxPage); - openInventory(player, 1); - } - - } - - /** - * Open inventory. - * - * @param player the player - * @param page the page - */ - public void openInventory(Player player, int page) { - BInventory inventory = this; - addPlaceholder("currentpage", "" + page); - inv = Bukkit.createInventory(new GUISession(this, page), maxInvSize, PlaceholderUtils.replaceJavascript(player, - PlaceholderUtils.replacePlaceHolder(inventory.getInventoryName(), getPlaceholders()))); - this.page = page; - int startSlot = (page - 1) * (maxInvSize - 9); - for (Entry pair : inventory.getButtons().entrySet()) { - int slot = pair.getKey(); - if (slot >= startSlot) { - slot -= startSlot; - if (slot < (maxInvSize - 9)) { - ItemStack item = pair.getValue().getItem(player, getPlaceholders()); - inv.setItem(slot, item); - - BInventoryButton b = pair.getValue(); - b.setInv(this); - b.setSlot(pair.getKey()); - b.load(player); - } - } - - } - - for (BInventoryButton b : pageButtons) { - inv.setItem((maxInvSize - 9) + b.getSlot(), b.getItem(player, getPlaceholders())); - } - if (prevItem == null) { - if (AdvancedCorePlugin.getInstance().getOptions().getPrevItem() != null) { - prevItem = new ItemBuilder(AdvancedCorePlugin.getInstance().getOptions().getPrevItem()) - .addPlaceholder(getPlaceholders()).toItemStack(player); - } else { - prevItem = new ItemBuilder(Material.BLACK_STAINED_GLASS_PANE, 1).setName("&aPrevious Page") - .addPlaceholder(getPlaceholders()).toItemStack(player); - } - } - if (nextItem == null) { - if (AdvancedCorePlugin.getInstance().getOptions().getNextItem() != null) { - nextItem = new ItemBuilder(AdvancedCorePlugin.getInstance().getOptions().getNextItem()) - .addPlaceholder(getPlaceholders()).toItemStack(player); - } else { - nextItem = new ItemBuilder(Material.BLACK_STAINED_GLASS_PANE, 1).setName("&aNext Page") - .addPlaceholder(getPlaceholders()).toItemStack(player); - } - } - - inv.setItem(maxInvSize - 9, prevItem); - - inv.setItem(maxInvSize - 1, nextItem); - - openInv(player, inv); - } - - /** - * Play sound for a player. - * - * @param player the player - */ - public void playSound(Player player) { - if (playerSound) { - Sound sound = AdvancedCorePlugin.getInstance().getOptions().getClickSoundSound(); - if (sound != null) { - player.playSound(player.getLocation(), sound, - (float) AdvancedCorePlugin.getInstance().getOptions().getClickSoundVolume(), - (float) AdvancedCorePlugin.getInstance().getOptions().getClickSoundPitch()); - } - } - } - - /** - * Require permission to open this inventory. - * - * @param permission the permission required - */ - public void requirePermission(String permission) { - this.perm = permission; - } - - /** - * @param buttons the buttons to set - */ - public void setButtons(Map buttons) { - this.buttons = buttons; - } - - /** - * Set whether the inventory should close after interaction. - * - * @param value whether to close - * @return this inventory - */ - public BInventory setCloseInv(boolean value) { - closeInv = value; - return this; - } - - /** - * Sets the inventory name. - * - * @param inventoryName the new inventory name - */ - public void setInventoryName(String inventoryName) { - this.inventoryName = MessageAPI.colorize(inventoryName); - } - - /** - * @param maxInvSize the maxInvSize to set - */ - public void setMaxInvSize(int maxInvSize) { - this.maxInvSize = getProperSize(maxInvSize); - } - - /** - * Sets the meta. - * - * @param player the player - * @param str the str - * @param ob the ob - */ - public void setMeta(Player player, String str, Object ob) { - PlayerUtils.setPlayerMeta(AdvancedCorePlugin.getInstance(), player, str, ob); - } - - /** - * @param nextItem the nextItem to set - */ - public void setNextItem(ItemStack nextItem) { - this.nextItem = nextItem; - } - - /** - * @param pageButtons the pageButtons to set - */ - public void setPageButtons(ArrayList pageButtons) { - this.pageButtons = pageButtons; - } - - /** - * @param pages the pages to set - */ - public void setPages(boolean pages) { - this.pages = pages; - } - - /** - * @param prevItem the prevItem to set - */ - public void setPrevItem(ItemStack prevItem) { - this.prevItem = prevItem; - } - -} \ No newline at end of file +/* Obtained from https://www.spigotmc.org/threads/libish-inventory-api-kinda.49339/ + * and modified + */ + +package com.bencodez.advancedcore.api.inventory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.simpleapi.messages.MessageAPI; +import com.bencodez.simpleapi.player.PlayerUtils; + +import lombok.Getter; +import lombok.Setter; + +/** + * Inventory GUI with button, pagination and update-task lifecycle management. + */ +public class BInventory { + + public class ClickEvent { + @Getter + private final BInventoryButton button; + @Getter + private final ClickType click; + @Getter + private final ItemStack clickedItem; + @Getter + private final InventoryClickEvent event; + @Getter + private final Inventory inventory; + @Getter + private final Player player; + @Getter + private final int slot; + + public ClickEvent(InventoryClickEvent event, BInventoryButton button) { + this.event = event; + this.player = (Player) event.getWhoClicked(); + this.click = event.getClick(); + this.inventory = event.getInventory(); + this.clickedItem = event.getCurrentItem(); + this.slot = event.getSlot(); + this.button = button; + } + + public void closeInventory() { + runSync(() -> { + if (player != null) { + player.closeInventory(); + } + }); + } + + public ItemStack getCurrentItem() { + return clickedItem; + } + + public Object getMeta(Player player, String str) { + return PlayerUtils.getPlayerMeta(AdvancedCorePlugin.getInstance(), player, str); + } + + public Object getMeta(String str) { + return PlayerUtils.getPlayerMeta(AdvancedCorePlugin.getInstance(), player, str); + } + + public Player getWhoClicked() { + return player; + } + + public void runSync(Runnable runnable) { + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + plugin.getBukkitScheduler().runTask(plugin, runnable); + } + } + + public static void openInventory(Player player, BInventory inventory) { + inventory.openInventory(player); + } + + private Map buttons = new HashMap<>(); + @Getter + private boolean closeInv = true; + private final HashMap data = new HashMap<>(); + private final ArrayList fillItems = new ArrayList<>(); + private final List> futures = new CopyOnWriteArrayList<>(); + private final Map>> playerFutures = new ConcurrentHashMap<>(); + private Inventory inv; + private String inventoryName; + @Getter + @Setter + private long lastPressTime = 0; + private int maxInvSize = 54; + @Getter + private int maxPage = 1; + private ItemStack nextItem; + @Getter + private int page = 1; + private ArrayList pageButtons = new ArrayList<>(); + private boolean pages = false; + private String perm; + @Getter + private final HashMap placeholders = new HashMap<>(); + @Getter + @Setter + private boolean playerSound = true; + private ItemStack prevItem; + + public BInventory(String name) { + setInventoryName(name); + } + + /** + * Adds a button. Slot -1 selects the next slot and -2 selects the final slot of + * the current inventory row size. + */ + public void addButton(BInventoryButton button) { + int slot = button.getSlot(); + if (slot == -1) { + slot = getNextSlot(); + } else if (slot == -2) { + slot = getProperSize(getNextSlot()) - 1; + } + + if (button.isFillEmptySlots()) { + fillItems.add(button); + return; + } + + List fillSlots = button.getFillSlots(); + if (fillSlots != null && !fillSlots.isEmpty()) { + for (Integer fillSlot : fillSlots) { + if (fillSlot != null) { + buttons.put(fillSlot, copyButton(button, fillSlot)); + } + } + return; + } + + button.setSlot(slot); + buttons.put(slot, button); + } + + public boolean isSlotTaken(int slot) { + return buttons.containsKey(slot); + } + + public void addButton(int position, BInventoryButton button) { + buttons.put(position, button); + } + + public BInventory addData(String key, Object object) { + data.put(key, object); + return this; + } + + public BInventory addPlaceholder(String toReplace, String replaceWith) { + placeholders.put(toReplace, replaceWith); + return this; + } + + /** + * Compatibility form for update tasks that are not associated with a player. + */ + public void addUpdatingButton(AdvancedCorePlugin plugin, long delay, long interval, Runnable runnable) { + futures.add(plugin.getInventoryTimer().scheduleWithFixedDelay(runnable, delay, interval, TimeUnit.MILLISECONDS)); + } + + /** + * Tracks an updating task for one viewer so closing another player's GUI does + * not cancel it. + */ + public void addUpdatingButton(Player player, AdvancedCorePlugin plugin, long delay, long interval, + Runnable runnable) { + ScheduledFuture future = plugin.getInventoryTimer().scheduleWithFixedDelay(runnable, delay, interval, + TimeUnit.MILLISECONDS); + trackFuture(player, future); + } + + void addDelayedTask(Player player, AdvancedCorePlugin plugin, long delay, Runnable runnable) { + ScheduledFuture future = plugin.getInventoryTimer().schedule(runnable, delay, TimeUnit.MILLISECONDS); + trackFuture(player, future); + } + + /** + * Cancels every task owned by this GUI. + */ + public void cancelTimer() { + cancelFutures(futures); + futures.clear(); + for (List> viewerFutures : playerFutures.values()) { + cancelFutures(viewerFutures); + } + playerFutures.clear(); + } + + /** + * Cancels only tasks associated with one viewer. + */ + public void cancelTimer(Player player) { + if (player == null) { + return; + } + List> viewerFutures = playerFutures.remove(player.getUniqueId()); + cancelFutures(viewerFutures); + } + + public void closeInv(Player player, BInventoryButton button) { + Inventory topInventory = PlayerUtils.getTopInventory(player); + if (topInventory == null || inv == null || !topInventory.equals(inv)) { + return; + } + + if (pages || (closeInv && (button == null || !button.isCloseInvSet()))) { + forceClose(player); + return; + } + if (button != null && button.isCloseInvSet() && button.isCloseInv()) { + forceClose(player); + } + } + + public BInventory dontClose() { + closeInv = false; + return this; + } + + public void forceClose(Player player) { + cancelTimer(player); + if (player == null) { + return; + } + if (Bukkit.isPrimaryThread()) { + player.closeInventory(); + return; + } + + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + plugin.getBukkitScheduler().runTask(plugin, player::closeInventory, player); + } + + public Map getButtons() { + return buttons; + } + + public HashMap getData() { + return data; + } + + public Object getData(String key) { + return data.get(key); + } + + public Object getData(String key, Object defaultValue) { + return data.containsKey(key) ? data.get(key) : defaultValue; + } + + public int getFirstEmptySlot() { + if (buttons.isEmpty()) { + return 0; + } + for (int i = 0; i < getInventorySize(); i++) { + if (!buttons.containsKey(i)) { + return i; + } + } + return getHighestSlot() + 1; + } + + public int getHighestSlot() { + int highest = 0; + for (int slot : buttons.keySet()) { + if (slot > highest) { + highest = slot; + } + } + return highest; + } + + public String getInventoryName() { + return inventoryName; + } + + public int getInventorySize() { + return getProperSize(getHighestSlot()); + } + + public int getMaxInvSize() { + return maxInvSize; + } + + public Object getMeta(Player player, String str) { + return PlayerUtils.getPlayerMeta(AdvancedCorePlugin.getInstance(), player, str); + } + + public ItemStack getNextItem() { + return nextItem; + } + + public int getNextSlot() { + return buttons.isEmpty() ? 0 : getHighestSlot() + 1; + } + + public ArrayList getPageButtons() { + return pageButtons; + } + + public ItemStack getPrevItem() { + return prevItem; + } + + public boolean isOpen(Player player) { + GUISession session = GUISession.extractSession(player); + return session != null && session.getInventoryGUI() == this; + } + + public boolean isPages() { + return pages; + } + + public BInventory noSound() { + playerSound = false; + return this; + } + + public void onClick(InventoryClickEvent event, BInventoryButton button) { + playSound((Player) event.getWhoClicked()); + button.onClick(new ClickEvent(event, button), this); + } + + public void openInventory(Player player) { + if (player.isSleeping()) { + AdvancedCorePlugin.getInstance().debug(player.getName() + " is sleeping, not opening gui!"); + return; + } + if (!hasPermission(player)) { + return; + } + + addFillSlots(); + if (getHighestSlot() >= maxInvSize) { + pages = true; + } + + if (pages) { + maxPage = InventoryPagination.getPageCount(getHighestSlot(), maxInvSize); + addPlaceholder("totalpages", String.valueOf(maxPage)); + openInventory(player, 1); + return; + } + + cancelTimer(player); + inv = Bukkit.createInventory(new GUISession(this, 1), getInventorySize(), getDisplayName(player)); + for (Entry entry : buttons.entrySet()) { + loadButton(player, entry.getValue(), entry.getKey(), entry.getKey()); + } + openInv(player, inv); + } + + public void openInventory(Player player, int page) { + if (page < 1) { + throw new IllegalArgumentException("Page must be >= 1"); + } + + maxPage = InventoryPagination.getPageCount(getHighestSlot(), maxInvSize); + int targetPage = Math.min(page, maxPage); + cancelTimer(player); + addPlaceholder("currentpage", String.valueOf(targetPage)); + addPlaceholder("totalpages", String.valueOf(maxPage)); + + inv = Bukkit.createInventory(new GUISession(this, targetPage), maxInvSize, getDisplayName(player)); + this.page = targetPage; + + int contentSize = InventoryPagination.getContentSize(maxInvSize); + int startSlot = (targetPage - 1) * contentSize; + for (Entry entry : buttons.entrySet()) { + int sourceSlot = entry.getKey(); + if (sourceSlot < startSlot || sourceSlot >= startSlot + contentSize) { + continue; + } + loadButton(player, entry.getValue(), sourceSlot, sourceSlot - startSlot); + } + + for (BInventoryButton button : pageButtons) { + int displayedSlot = contentSize + button.getSlot(); + inv.setItem(displayedSlot, button.getItem(player, placeholders)); + button.setInv(this); + } + + loadNavigationItems(player); + inv.setItem(contentSize, prevItem); + inv.setItem(maxInvSize - 1, nextItem); + openInv(player, inv); + } + + public void playSound(Player player) { + if (!playerSound) { + return; + } + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + Sound sound = plugin.getOptions().getClickSoundSound(); + if (sound != null) { + player.playSound(player.getLocation(), sound, (float) plugin.getOptions().getClickSoundVolume(), + (float) plugin.getOptions().getClickSoundPitch()); + } + } + + public void requirePermission(String permission) { + this.perm = permission; + } + + public void setButtons(Map buttons) { + this.buttons = buttons; + } + + public BInventory setCloseInv(boolean value) { + closeInv = value; + return this; + } + + public void setInventoryName(String inventoryName) { + this.inventoryName = MessageAPI.colorize(inventoryName); + } + + public void setMaxInvSize(int maxInvSize) { + this.maxInvSize = getProperSize(maxInvSize); + } + + public void setMeta(Player player, String str, Object ob) { + PlayerUtils.setPlayerMeta(AdvancedCorePlugin.getInstance(), player, str, ob); + } + + public void setNextItem(ItemStack nextItem) { + this.nextItem = nextItem; + } + + public void setPageButtons(ArrayList pageButtons) { + this.pageButtons = pageButtons; + } + + public void setPages(boolean pages) { + this.pages = pages; + if (!pages) { + maxPage = 1; + } + } + + public void setPrevItem(ItemStack prevItem) { + this.prevItem = prevItem; + } + + private void addFillSlots() { + if (fillItems.isEmpty()) { + return; + } + int inventorySize = getInventorySize(); + for (BInventoryButton button : fillItems) { + for (int slot = 0; slot < inventorySize; slot++) { + if (!buttons.containsKey(slot)) { + buttons.put(slot, copyButton(button, slot)); + } + } + } + fillItems.clear(); + } + + private BInventoryButton copyButton(BInventoryButton button, int slot) { + BInventoryButton copy = new BInventoryButton(button) { + @Override + public void onClick(ClickEvent clickEvent) { + button.onClick(clickEvent); + } + }; + copy.setSlot(slot); + return copy; + } + + private void cancelFutures(List> scheduledFutures) { + if (scheduledFutures == null) { + return; + } + for (ScheduledFuture future : scheduledFutures) { + if (future != null) { + future.cancel(true); + } + } + } + + private String getDisplayName(Player player) { + return PlaceholderUtils.replaceJavascript(player, + PlaceholderUtils.replacePlaceHolder(inventoryName, placeholders)); + } + + private int getProperSize(int size) { + if (size < 9) { + return 9; + } + if (size < 18) { + return 18; + } + if (size < 27) { + return 27; + } + if (size < 36) { + return 36; + } + if (size < 45) { + return 45; + } + return 54; + } + + private boolean hasPermission(Player player) { + if (perm == null) { + return true; + } + + if (!perm.contains("|")) { + if (player.hasPermission(perm)) { + return true; + } + } else { + for (String permission : perm.split(Pattern.quote("|"))) { + if (player.hasPermission(permission)) { + return true; + } + } + } + + player.sendMessage(MessageAPI.colorize(AdvancedCorePlugin.getInstance().getOptions().getFormatNoPerms())); + return false; + } + + private void loadButton(Player player, BInventoryButton button, int sourceSlot, int displayedSlot) { + inv.setItem(displayedSlot, button.getItem(player, placeholders)); + button.setInv(this); + button.setSlot(sourceSlot); + button.load(player); + } + + private void loadNavigationItems(Player player) { + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + if (prevItem == null) { + if (plugin.getOptions().getPrevItem() != null) { + prevItem = new ItemBuilder(plugin.getOptions().getPrevItem()).addPlaceholder(placeholders).toItemStack(player); + } else { + prevItem = new ItemBuilder(Material.BLACK_STAINED_GLASS_PANE, 1).setName("&aPrevious Page") + .addPlaceholder(placeholders).toItemStack(player); + } + } + if (nextItem == null) { + if (plugin.getOptions().getNextItem() != null) { + nextItem = new ItemBuilder(plugin.getOptions().getNextItem()).addPlaceholder(placeholders).toItemStack(player); + } else { + nextItem = new ItemBuilder(Material.BLACK_STAINED_GLASS_PANE, 1).setName("&aNext Page") + .addPlaceholder(placeholders).toItemStack(player); + } + } + } + + private void openInv(Player player, Inventory inventory) { + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + plugin.getBukkitScheduler().runTask(plugin, () -> player.openInventory(inventory), player); + } + + private void trackFuture(Player player, ScheduledFuture future) { + if (player == null) { + futures.add(future); + return; + } + playerFutures.computeIfAbsent(player.getUniqueId(), ignored -> new CopyOnWriteArrayList<>()).add(future); + } +} From 8021c3168cc655e0caaec95825f2180db0b75622 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:24:43 -0600 Subject: [PATCH 06/23] Track updating button tasks per viewer --- .../inventory/UpdatingBInventoryButton.java | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java index ae52d2a0f9..f3e9440e86 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java @@ -1,7 +1,6 @@ package com.bencodez.advancedcore.api.inventory; import java.util.List; -import java.util.concurrent.TimeUnit; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; @@ -58,7 +57,7 @@ public void load(Player player) { if (inventory == null) { return; } - inventory.addUpdatingButton(plugin, delay, updateInterval, () -> scheduleUpdate(player, true)); + inventory.addUpdatingButton(player, plugin, delay, updateInterval, () -> scheduleUpdate(player, true)); } @Override @@ -69,7 +68,7 @@ public void onClick(ClickEvent event, BInventory inventory) { } if (clickUpdateDelay > 0) { - plugin.getInventoryTimer().schedule(() -> update(event.getPlayer()), clickUpdateDelay, TimeUnit.MILLISECONDS); + inventory.addDelayedTask(event.getPlayer(), plugin, clickUpdateDelay, () -> update(event.getPlayer())); } else { update(event.getPlayer()); } @@ -92,9 +91,7 @@ private void scheduleUpdate(Player player, boolean cancelWhenUnavailable) { return; } if (!plugin.isEnabled()) { - if (cancelWhenUnavailable) { - inventory.cancelTimer(); - } + cancelIfRequested(inventory, player, cancelWhenUnavailable); return; } @@ -104,7 +101,7 @@ private void scheduleUpdate(Player player, boolean cancelWhenUnavailable) { private void applyUpdate(Player player, boolean cancelWhenUnavailable) { BInventory inventory = getInv(); if (inventory == null || player == null || !plugin.isEnabled()) { - cancelIfRequested(inventory, cancelWhenUnavailable); + cancelIfRequested(inventory, player, cancelWhenUnavailable); return; } @@ -112,26 +109,26 @@ private void applyUpdate(Player player, boolean cancelWhenUnavailable) { return; } if (!inventory.isOpen(player)) { - cancelIfRequested(inventory, cancelWhenUnavailable); + cancelIfRequested(inventory, player, cancelWhenUnavailable); return; } try { ItemBuilder builder = onUpdate(player); if (builder == null) { - cancelIfRequested(inventory, cancelWhenUnavailable); + cancelIfRequested(inventory, player, cancelWhenUnavailable); return; } ItemStack item = builder.toItemStack(player); if (item == null) { - cancelIfRequested(inventory, cancelWhenUnavailable); + cancelIfRequested(inventory, player, cancelWhenUnavailable); return; } Inventory topInventory = PlayerUtils.getTopInventory(player); if (topInventory == null) { - cancelIfRequested(inventory, cancelWhenUnavailable); + cancelIfRequested(inventory, player, cancelWhenUnavailable); return; } @@ -141,19 +138,18 @@ private void applyUpdate(Player player, boolean cancelWhenUnavailable) { if (slot != null) { topInventory.setItem(slot.intValue(), item); } - } } else { topInventory.setItem(getSlot(), item); } } catch (Exception exception) { plugin.debug(exception); - cancelIfRequested(inventory, cancelWhenUnavailable); + cancelIfRequested(inventory, player, cancelWhenUnavailable); } } - private void cancelIfRequested(BInventory inventory, boolean cancelWhenUnavailable) { + private void cancelIfRequested(BInventory inventory, Player player, boolean cancelWhenUnavailable) { if (cancelWhenUnavailable && inventory != null) { - inventory.cancelTimer(); + inventory.cancelTimer(player); } } } From 2114b15404713510d41c1927d594c049c59eb04e Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:25:17 -0600 Subject: [PATCH 07/23] Use shared pagination mapping in GUI listener --- .../advancedcore/api/inventory/BInventoryListener.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java index 779e61005c..400331c552 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java @@ -102,11 +102,11 @@ private boolean isSpamClick(Player player, BInventory gui, InventoryClickEvent e private void handlePagedClick(InventoryClickEvent event, Player player, BInventory gui, GUISession session) { int slot = event.getSlot(); int maxInventorySize = gui.getMaxInvSize(); - int contentSize = maxInventorySize - 9; + int contentSize = InventoryPagination.getContentSize(maxInventorySize); int page = session.getPage(); - if (slot < contentSize) { - int buttonSlot = (page - 1) * contentSize + slot; + if (InventoryPagination.isContentSlot(slot, maxInventorySize)) { + int buttonSlot = InventoryPagination.getButtonSlot(page, slot, maxInventorySize); BInventoryButton button = gui.getButtons().get(buttonSlot); if (button != null) { handleButtonClick(event, player, gui, button); From 61323ba4b9198b5b5a653560a9a66fc0ceab033e Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:25:43 -0600 Subject: [PATCH 08/23] Test per-viewer inventory timer ownership --- .../tests/inventory/BInventoryTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java index 2943b530c5..91504e9c41 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java @@ -3,13 +3,16 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.UUID; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; import com.bencodez.advancedcore.AdvancedCorePlugin; @@ -35,4 +38,36 @@ public void updatingButtonUsesConfiguredIntervalAndCancelsFuture() { verify(timer).scheduleWithFixedDelay(runnable, 100L, 250L, TimeUnit.MILLISECONDS); verify(future).cancel(true); } + + @Test + @SuppressWarnings("rawtypes") + public void viewerCancellationDoesNotCancelOtherViewerTasks() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + ScheduledFuture firstFuture = mock(ScheduledFuture.class); + ScheduledFuture secondFuture = mock(ScheduledFuture.class); + Player firstPlayer = mock(Player.class); + Player secondPlayer = mock(Player.class); + Runnable firstRunnable = mock(Runnable.class); + Runnable secondRunnable = mock(Runnable.class); + when(plugin.getInventoryTimer()).thenReturn(timer); + when(firstPlayer.getUniqueId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000001")); + when(secondPlayer.getUniqueId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000002")); + doReturn(firstFuture).when(timer).scheduleWithFixedDelay(eq(firstRunnable), eq(10L), eq(20L), + eq(TimeUnit.MILLISECONDS)); + doReturn(secondFuture).when(timer).scheduleWithFixedDelay(eq(secondRunnable), eq(30L), eq(40L), + eq(TimeUnit.MILLISECONDS)); + + BInventory inventory = new BInventory("Test"); + inventory.addUpdatingButton(firstPlayer, plugin, 10L, 20L, firstRunnable); + inventory.addUpdatingButton(secondPlayer, plugin, 30L, 40L, secondRunnable); + + inventory.cancelTimer(firstPlayer); + + verify(firstFuture).cancel(true); + verify(secondFuture, never()).cancel(true); + + inventory.cancelTimer(); + verify(secondFuture).cancel(true); + } } From fb9534fb4ee8abdb3fe40806a1307fe5e2135fac Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:25:51 -0600 Subject: [PATCH 09/23] Test inventory pagination boundaries --- .../inventory/InventoryPaginationTest.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/InventoryPaginationTest.java diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/InventoryPaginationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/InventoryPaginationTest.java new file mode 100644 index 0000000000..39519a7505 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/InventoryPaginationTest.java @@ -0,0 +1,38 @@ +package com.bencodez.advancedcore.tests.inventory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.inventory.InventoryPagination; + +public class InventoryPaginationTest { + + @Test + public void pageCountHandlesZeroBasedSlotBoundary() { + assertEquals(45, InventoryPagination.getContentSize(54)); + assertEquals(1, InventoryPagination.getPageCount(44, 54)); + assertEquals(2, InventoryPagination.getPageCount(45, 54)); + assertEquals(2, InventoryPagination.getPageCount(89, 54)); + assertEquals(3, InventoryPagination.getPageCount(90, 54)); + } + + @Test + public void displayedSlotsMapBackToSourceSlots() { + assertEquals(0, InventoryPagination.getButtonSlot(1, 0, 54)); + assertEquals(44, InventoryPagination.getButtonSlot(1, 44, 54)); + assertEquals(45, InventoryPagination.getButtonSlot(2, 0, 54)); + assertEquals(89, InventoryPagination.getButtonSlot(2, 44, 54)); + assertThrows(IllegalArgumentException.class, () -> InventoryPagination.getButtonSlot(0, 0, 54)); + } + + @Test + public void contentSlotCheckExcludesNavigationRow() { + assertTrue(InventoryPagination.isContentSlot(44, 54)); + assertFalse(InventoryPagination.isContentSlot(45, 54)); + assertFalse(InventoryPagination.isContentSlot(53, 54)); + } +} From 262efe5d3e9e0e52255e343c4f3575e8ef443750 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:26:09 -0600 Subject: [PATCH 10/23] Test GUI session validation and extraction --- .../tests/inventory/GUISessionTest.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/GUISessionTest.java diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/GUISessionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/GUISessionTest.java new file mode 100644 index 0000000000..d084ab2212 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/GUISessionTest.java @@ -0,0 +1,44 @@ +package com.bencodez.advancedcore.tests.inventory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.inventory.BInventory; +import com.bencodez.advancedcore.api.inventory.GUISession; + +public class GUISessionTest { + + @Test + public void constructorValidatesPageAndKeepsGuiReference() { + BInventory gui = mock(BInventory.class); + GUISession session = new GUISession(gui, 2); + + assertSame(gui, session.getInventoryGUI()); + assertEquals(2, session.getPage()); + assertThrows(IllegalArgumentException.class, () -> new GUISession(gui, 0)); + assertThrows(IllegalArgumentException.class, () -> new GUISession(null, 1)); + } + + @Test + public void inventoryExtractionOnlyReturnsGuiSessionHolders() { + BInventory gui = mock(BInventory.class); + GUISession session = new GUISession(gui, 1); + Inventory inventory = mock(Inventory.class); + when(inventory.getHolder()).thenReturn(session); + + assertSame(session, GUISession.extractSession(inventory)); + + Inventory unrelated = mock(Inventory.class); + when(unrelated.getHolder()).thenReturn(mock(InventoryHolder.class)); + assertNull(GUISession.extractSession(unrelated)); + assertNull(GUISession.extractSession((Inventory) null)); + } +} From 31d52dfe7316cc16108f25da7c2e6125567e7bdb Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:26:17 -0600 Subject: [PATCH 11/23] Test updating button viewer scheduling --- .../UpdatingBInventoryButtonTest.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/UpdatingBInventoryButtonTest.java diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/UpdatingBInventoryButtonTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/UpdatingBInventoryButtonTest.java new file mode 100644 index 0000000000..f110ad1a2d --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/UpdatingBInventoryButtonTest.java @@ -0,0 +1,40 @@ +package com.bencodez.advancedcore.tests.inventory; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.inventory.BInventory; +import com.bencodez.advancedcore.api.inventory.UpdatingBInventoryButton; +import com.bencodez.advancedcore.api.item.ItemBuilder; + +public class UpdatingBInventoryButtonTest { + + @Test + public void loadRegistersPeriodicUpdateForSpecificViewer() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + BInventory inventory = mock(BInventory.class); + Player player = mock(Player.class); + ItemBuilder item = mock(ItemBuilder.class); + UpdatingBInventoryButton button = new UpdatingBInventoryButton(plugin, item, 100L, 250L) { + @Override + public ItemBuilder onUpdate(Player player) { + return item; + } + + @Override + public void onClick(BInventory.ClickEvent clickEvent) { + } + }; + button.setInv(inventory); + + button.load(player); + + verify(inventory).addUpdatingButton(eq(player), eq(plugin), eq(100L), eq(250L), any(Runnable.class)); + } +} From c52095b1f7a2d0e167d1faafffc9fac23bba1e09 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:26:28 -0600 Subject: [PATCH 12/23] Test synchronous GUI click dispatch --- .../inventory/BInventoryListenerTest.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java new file mode 100644 index 0000000000..e05a018db2 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java @@ -0,0 +1,70 @@ +package com.bencodez.advancedcore.tests.inventory; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.inventory.BInventory; +import com.bencodez.advancedcore.api.inventory.BInventoryButton; +import com.bencodez.advancedcore.api.inventory.BInventoryListener; +import com.bencodez.advancedcore.api.inventory.GUISession; +import com.bencodez.simpleapi.player.PlayerUtils; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +public class BInventoryListenerTest { + + @Test + public void guiButtonClickStaysOnEventThread() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + InventoryClickEvent event = mock(InventoryClickEvent.class); + Player player = mock(Player.class); + InventoryView view = mock(InventoryView.class); + Inventory topInventory = mock(Inventory.class); + Inventory clickedInventory = mock(Inventory.class); + BInventory gui = mock(BInventory.class); + BInventoryButton button = mock(BInventoryButton.class); + GUISession session = new GUISession(gui, 1); + + when(plugin.getOptions()).thenReturn(options); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(options.getSpamClickTimeMs()).thenReturn(0L); + when(event.getWhoClicked()).thenReturn(player); + when(player.getOpenInventory()).thenReturn(view); + when(topInventory.getHolder()).thenReturn(session); + when(event.getClickedInventory()).thenReturn(clickedInventory); + when(clickedInventory.getType()).thenReturn(InventoryType.CHEST); + when(event.getInventory()).thenReturn(topInventory); + when(event.getSlot()).thenReturn(3); + when(gui.getButtons()).thenReturn(Map.of(3, button)); + when(gui.isPages()).thenReturn(false); + when(gui.isCloseInv()).thenReturn(false); + + try (MockedStatic playerUtils = mockStatic(PlayerUtils.class)) { + playerUtils.when(() -> PlayerUtils.getTopInventory(player)).thenReturn(topInventory); + + new BInventoryListener(plugin).onInventoryClick(event); + } + + verify(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + verify(scheduler, never()).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + verify(gui).onClick(event, button); + } +} From 04eed7b7612be2244c26392b9b385055442e4c12 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:27:57 -0600 Subject: [PATCH 13/23] Fix updating button fill-slot branch --- .../advancedcore/api/inventory/UpdatingBInventoryButton.java | 1 + 1 file changed, 1 insertion(+) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java index f3e9440e86..9f326e25e3 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/UpdatingBInventoryButton.java @@ -138,6 +138,7 @@ private void applyUpdate(Player player, boolean cancelWhenUnavailable) { if (slot != null) { topInventory.setItem(slot.intValue(), item); } + } } else { topInventory.setItem(getSlot(), item); } From 387d3a74adb28c6928f577b1080346703dfe9c99 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:28:58 -0600 Subject: [PATCH 14/23] Identify GUI clicks by top inventory --- .../advancedcore/api/inventory/BInventoryListener.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java index 400331c552..70f3a15457 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java @@ -7,11 +7,11 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryType; import org.bukkit.inventory.ItemStack; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.simpleapi.messages.MessageAPI; +import com.bencodez.simpleapi.player.PlayerUtils; /** * Listener for inventory interactions. @@ -41,7 +41,7 @@ public void onInventoryClick(InventoryClickEvent event) { event.setCancelled(true); event.setResult(Result.DENY); - if (event.getClickedInventory() == null || event.getClickedInventory().getType() != InventoryType.CHEST) { + if (event.getClickedInventory() == null || event.getClickedInventory() != PlayerUtils.getTopInventory(player)) { return; } From 090e8b2509ca711f5fd3e54a2363917dc5be7171 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:29:16 -0600 Subject: [PATCH 15/23] Avoid Bukkit registry initialization in listener test --- .../advancedcore/tests/inventory/BInventoryListenerTest.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java index e05a018db2..afd2d486a6 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java @@ -12,7 +12,6 @@ import org.bukkit.entity.Player; import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryType; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryView; import org.junit.jupiter.api.Test; @@ -38,7 +37,6 @@ public void guiButtonClickStaysOnEventThread() { Player player = mock(Player.class); InventoryView view = mock(InventoryView.class); Inventory topInventory = mock(Inventory.class); - Inventory clickedInventory = mock(Inventory.class); BInventory gui = mock(BInventory.class); BInventoryButton button = mock(BInventoryButton.class); GUISession session = new GUISession(gui, 1); @@ -49,8 +47,7 @@ public void guiButtonClickStaysOnEventThread() { when(event.getWhoClicked()).thenReturn(player); when(player.getOpenInventory()).thenReturn(view); when(topInventory.getHolder()).thenReturn(session); - when(event.getClickedInventory()).thenReturn(clickedInventory); - when(clickedInventory.getType()).thenReturn(InventoryType.CHEST); + when(event.getClickedInventory()).thenReturn(topInventory); when(event.getInventory()).thenReturn(topInventory); when(event.getSlot()).thenReturn(3); when(gui.getButtons()).thenReturn(Map.of(3, button)); From eae45bde43018ad846c5458e92f6af961a14e3a0 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:36:24 -0600 Subject: [PATCH 16/23] Cancel legacy GUI update tasks on force close --- .../bencodez/advancedcore/api/inventory/BInventory.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java index df906108e0..795e5b9fb9 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java @@ -207,8 +207,7 @@ void addDelayedTask(Player player, AdvancedCorePlugin plugin, long delay, Runnab * Cancels every task owned by this GUI. */ public void cancelTimer() { - cancelFutures(futures); - futures.clear(); + cancelLegacyTimers(); for (List> viewerFutures : playerFutures.values()) { cancelFutures(viewerFutures); } @@ -247,6 +246,7 @@ public BInventory dontClose() { } public void forceClose(Player player) { + cancelLegacyTimers(); cancelTimer(player); if (player == null) { return; @@ -507,6 +507,11 @@ private void cancelFutures(List> scheduledFutures) { } } + private void cancelLegacyTimers() { + cancelFutures(futures); + futures.clear(); + } + private String getDisplayName(Player player) { return PlaceholderUtils.replaceJavascript(player, PlaceholderUtils.replacePlaceHolder(inventoryName, placeholders)); From 3ec9e3420008b157a32c9dedde95f51c4f3b64ae Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 15:36:37 -0600 Subject: [PATCH 17/23] Test legacy timer cleanup on force close --- .../tests/inventory/BInventoryTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java index 91504e9c41..8572b50a5f 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java @@ -39,6 +39,24 @@ public void updatingButtonUsesConfiguredIntervalAndCancelsFuture() { verify(future).cancel(true); } + @Test + @SuppressWarnings("rawtypes") + public void legacyUpdatingButtonIsCancelledWhenGuiIsForceClosed() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + ScheduledFuture future = mock(ScheduledFuture.class); + Runnable runnable = mock(Runnable.class); + when(plugin.getInventoryTimer()).thenReturn(timer); + doReturn(future).when(timer).scheduleWithFixedDelay(eq(runnable), eq(100L), eq(250L), + eq(TimeUnit.MILLISECONDS)); + + BInventory inventory = new BInventory("Test"); + inventory.addUpdatingButton(plugin, 100L, 250L, runnable); + inventory.forceClose(null); + + verify(future).cancel(true); + } + @Test @SuppressWarnings("rawtypes") public void viewerCancellationDoesNotCancelOtherViewerTasks() { From 08efab8b3523091fb866aea21ad623f30559bec1 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 16:07:34 -0600 Subject: [PATCH 18/23] Preserve async GUI click callbacks by default --- .../api/inventory/BInventory.java | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java index 795e5b9fb9..73a07885e5 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java @@ -102,6 +102,8 @@ public static void openInventory(Player player, BInventory inventory) { private Map buttons = new HashMap<>(); @Getter private boolean closeInv = true; + @Getter + private boolean clickAsync = true; private final HashMap data = new HashMap<>(); private final ArrayList fillItems = new ArrayList<>(); private final List> futures = new CopyOnWriteArrayList<>(); @@ -434,6 +436,37 @@ public void setButtons(Map buttons) { this.buttons = buttons; } + /** + * Set whether button callbacks are dispatched asynchronously. This defaults to + * true for backwards compatibility with existing AdvancedCore GUI code. + * + * @param value true to run button callbacks asynchronously, false to run them + * on the inventory event thread + * @return this inventory + */ + public BInventory setClickAsync(boolean value) { + clickAsync = value; + return this; + } + + /** + * Convenience method for opting this inventory into synchronous button callbacks. + * + * @return this inventory + */ + public BInventory runClicksSync() { + return setClickAsync(false); + } + + /** + * Convenience method for explicitly using the legacy asynchronous callback mode. + * + * @return this inventory + */ + public BInventory runClicksAsync() { + return setClickAsync(true); + } + public BInventory setCloseInv(boolean value) { closeInv = value; return this; @@ -480,7 +513,6 @@ private void addFillSlots() { if (!buttons.containsKey(slot)) { buttons.put(slot, copyButton(button, slot)); } - } } fillItems.clear(); } From d28ea64c128b44b27bea6ac9780d02840890e5fc Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 16:08:02 -0600 Subject: [PATCH 19/23] Make GUI click callback threading configurable --- .../api/inventory/BInventoryListener.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java index 70f3a15457..f62bcd5464 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventoryListener.java @@ -144,10 +144,18 @@ private void handleButtonClick(InventoryClickEvent event, Player player, BInvent } gui.closeInv(player, button); - try { - gui.onClick(event, button); - } catch (Exception exception) { - plugin.debug(exception); + Runnable clickAction = () -> { + try { + gui.onClick(event, button); + } catch (Exception exception) { + plugin.debug(exception); + } + }; + + if (gui.isClickAsync()) { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, clickAction); + } else { + clickAction.run(); } } } From 39653a72af5f9266c06351027376eaee3ea270d2 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 16:08:24 -0600 Subject: [PATCH 20/23] Test configurable GUI click threading --- .../tests/inventory/BInventoryTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java index 8572b50a5f..2bd0aba461 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryTest.java @@ -1,5 +1,8 @@ package com.bencodez.advancedcore.tests.inventory; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -20,6 +23,19 @@ public class BInventoryTest { + @Test + public void clickCallbacksDefaultToAsyncAndCanOptIntoSync() { + BInventory inventory = new BInventory("Test"); + + assertTrue(inventory.isClickAsync()); + assertSame(inventory, inventory.runClicksSync()); + assertFalse(inventory.isClickAsync()); + assertSame(inventory, inventory.runClicksAsync()); + assertTrue(inventory.isClickAsync()); + assertSame(inventory, inventory.setClickAsync(false)); + assertFalse(inventory.isClickAsync()); + } + @Test @SuppressWarnings("rawtypes") public void updatingButtonUsesConfiguredIntervalAndCancelsFuture() { From ec103aac13af13c246cd681635fb3ec1c2d32d70 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 16:08:38 -0600 Subject: [PATCH 21/23] Test async default and sync GUI click modes --- .../inventory/BInventoryListenerTest.java | 99 +++++++++++++------ 1 file changed, 69 insertions(+), 30 deletions(-) diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java index afd2d486a6..d84242cc52 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/inventory/BInventoryListenerTest.java @@ -15,6 +15,7 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryView; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import com.bencodez.advancedcore.AdvancedCoreConfigOptions; @@ -29,39 +30,77 @@ public class BInventoryListenerTest { @Test - public void guiButtonClickStaysOnEventThread() { - AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); - AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); - BukkitScheduler scheduler = mock(BukkitScheduler.class); - InventoryClickEvent event = mock(InventoryClickEvent.class); - Player player = mock(Player.class); - InventoryView view = mock(InventoryView.class); - Inventory topInventory = mock(Inventory.class); - BInventory gui = mock(BInventory.class); - BInventoryButton button = mock(BInventoryButton.class); - GUISession session = new GUISession(gui, 1); - - when(plugin.getOptions()).thenReturn(options); - when(plugin.getBukkitScheduler()).thenReturn(scheduler); - when(options.getSpamClickTimeMs()).thenReturn(0L); - when(event.getWhoClicked()).thenReturn(player); - when(player.getOpenInventory()).thenReturn(view); - when(topInventory.getHolder()).thenReturn(session); - when(event.getClickedInventory()).thenReturn(topInventory); - when(event.getInventory()).thenReturn(topInventory); - when(event.getSlot()).thenReturn(3); - when(gui.getButtons()).thenReturn(Map.of(3, button)); - when(gui.isPages()).thenReturn(false); - when(gui.isCloseInv()).thenReturn(false); + public void guiButtonClickDefaultsToAsyncCallback() { + TestContext context = createContext(true); + ArgumentCaptor asyncTask = ArgumentCaptor.forClass(Runnable.class); - try (MockedStatic playerUtils = mockStatic(PlayerUtils.class)) { - playerUtils.when(() -> PlayerUtils.getTopInventory(player)).thenReturn(topInventory); + runListener(context); + + verify(context.scheduler).runTask(eq(context.plugin), any(Runnable.class), eq(context.player)); + verify(context.scheduler).runTaskAsynchronously(eq(context.plugin), asyncTask.capture()); + verify(context.gui, never()).onClick(context.event, context.button); + + asyncTask.getValue().run(); + verify(context.gui).onClick(context.event, context.button); + } + + @Test + public void guiButtonClickCanOptIntoSyncCallback() { + TestContext context = createContext(false); + + runListener(context); - new BInventoryListener(plugin).onInventoryClick(event); + verify(context.scheduler).runTask(eq(context.plugin), any(Runnable.class), eq(context.player)); + verify(context.scheduler, never()).runTaskAsynchronously(eq(context.plugin), any(Runnable.class)); + verify(context.gui).onClick(context.event, context.button); + } + + private TestContext createContext(boolean clickAsync) { + TestContext context = new TestContext(); + context.plugin = mock(AdvancedCorePlugin.class); + context.options = mock(AdvancedCoreConfigOptions.class); + context.scheduler = mock(BukkitScheduler.class); + context.event = mock(InventoryClickEvent.class); + context.player = mock(Player.class); + context.view = mock(InventoryView.class); + context.topInventory = mock(Inventory.class); + context.gui = mock(BInventory.class); + context.button = mock(BInventoryButton.class); + context.session = new GUISession(context.gui, 1); + + when(context.plugin.getOptions()).thenReturn(context.options); + when(context.plugin.getBukkitScheduler()).thenReturn(context.scheduler); + when(context.options.getSpamClickTimeMs()).thenReturn(0L); + when(context.event.getWhoClicked()).thenReturn(context.player); + when(context.player.getOpenInventory()).thenReturn(context.view); + when(context.topInventory.getHolder()).thenReturn(context.session); + when(context.event.getClickedInventory()).thenReturn(context.topInventory); + when(context.event.getInventory()).thenReturn(context.topInventory); + when(context.event.getSlot()).thenReturn(3); + when(context.gui.getButtons()).thenReturn(Map.of(3, context.button)); + when(context.gui.isPages()).thenReturn(false); + when(context.gui.isCloseInv()).thenReturn(false); + when(context.gui.isClickAsync()).thenReturn(clickAsync); + return context; + } + + private void runListener(TestContext context) { + try (MockedStatic playerUtils = mockStatic(PlayerUtils.class)) { + playerUtils.when(() -> PlayerUtils.getTopInventory(context.player)).thenReturn(context.topInventory); + new BInventoryListener(context.plugin).onInventoryClick(context.event); } + } - verify(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); - verify(scheduler, never()).runTaskAsynchronously(eq(plugin), any(Runnable.class)); - verify(gui).onClick(event, button); + private static class TestContext { + private AdvancedCorePlugin plugin; + private AdvancedCoreConfigOptions options; + private BukkitScheduler scheduler; + private InventoryClickEvent event; + private Player player; + private InventoryView view; + private Inventory topInventory; + private BInventory gui; + private BInventoryButton button; + private GUISession session; } } From 2f78248231eb3ce01eab50b8336a3662a866b8f3 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 16:09:58 -0600 Subject: [PATCH 22/23] Fix fill-slot loop after click mode update --- .../com/bencodez/advancedcore/api/inventory/BInventory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java index 73a07885e5..a131cc39ce 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java @@ -513,6 +513,7 @@ private void addFillSlots() { if (!buttons.containsKey(slot)) { buttons.put(slot, copyButton(button, slot)); } + } } fillItems.clear(); } @@ -583,7 +584,6 @@ private boolean hasPermission(Player player) { return true; } } - } player.sendMessage(MessageAPI.colorize(AdvancedCorePlugin.getInstance().getOptions().getFormatNoPerms())); return false; From fdc19aa506508d196846f5302b063467911f43cd Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 23 Aug 2026 16:11:28 -0600 Subject: [PATCH 23/23] Fix permission block after click mode update --- .../api/inventory/BInventory.java | 54 +------------------ 1 file changed, 2 insertions(+), 52 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java index a131cc39ce..38c21349ac 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/inventory/BInventory.java @@ -133,10 +133,6 @@ public BInventory(String name) { setInventoryName(name); } - /** - * Adds a button. Slot -1 selects the next slot and -2 selects the final slot of - * the current inventory row size. - */ public void addButton(BInventoryButton button) { int slot = button.getSlot(); if (slot == -1) { @@ -144,12 +140,10 @@ public void addButton(BInventoryButton button) { } else if (slot == -2) { slot = getProperSize(getNextSlot()) - 1; } - if (button.isFillEmptySlots()) { fillItems.add(button); return; } - List fillSlots = button.getFillSlots(); if (fillSlots != null && !fillSlots.isEmpty()) { for (Integer fillSlot : fillSlots) { @@ -159,7 +153,6 @@ public void addButton(BInventoryButton button) { } return; } - button.setSlot(slot); buttons.put(slot, button); } @@ -182,19 +175,11 @@ public BInventory addPlaceholder(String toReplace, String replaceWith) { return this; } - /** - * Compatibility form for update tasks that are not associated with a player. - */ public void addUpdatingButton(AdvancedCorePlugin plugin, long delay, long interval, Runnable runnable) { futures.add(plugin.getInventoryTimer().scheduleWithFixedDelay(runnable, delay, interval, TimeUnit.MILLISECONDS)); } - /** - * Tracks an updating task for one viewer so closing another player's GUI does - * not cancel it. - */ - public void addUpdatingButton(Player player, AdvancedCorePlugin plugin, long delay, long interval, - Runnable runnable) { + public void addUpdatingButton(Player player, AdvancedCorePlugin plugin, long delay, long interval, Runnable runnable) { ScheduledFuture future = plugin.getInventoryTimer().scheduleWithFixedDelay(runnable, delay, interval, TimeUnit.MILLISECONDS); trackFuture(player, future); @@ -205,9 +190,6 @@ void addDelayedTask(Player player, AdvancedCorePlugin plugin, long delay, Runnab trackFuture(player, future); } - /** - * Cancels every task owned by this GUI. - */ public void cancelTimer() { cancelLegacyTimers(); for (List> viewerFutures : playerFutures.values()) { @@ -216,9 +198,6 @@ public void cancelTimer() { playerFutures.clear(); } - /** - * Cancels only tasks associated with one viewer. - */ public void cancelTimer(Player player) { if (player == null) { return; @@ -232,7 +211,6 @@ public void closeInv(Player player, BInventoryButton button) { if (topInventory == null || inv == null || !topInventory.equals(inv)) { return; } - if (pages || (closeInv && (button == null || !button.isCloseInvSet()))) { forceClose(player); return; @@ -257,7 +235,6 @@ public void forceClose(Player player) { player.closeInventory(); return; } - AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); plugin.getBukkitScheduler().runTask(plugin, player::closeInventory, player); } @@ -359,19 +336,16 @@ public void openInventory(Player player) { if (!hasPermission(player)) { return; } - addFillSlots(); if (getHighestSlot() >= maxInvSize) { pages = true; } - if (pages) { maxPage = InventoryPagination.getPageCount(getHighestSlot(), maxInvSize); addPlaceholder("totalpages", String.valueOf(maxPage)); openInventory(player, 1); return; } - cancelTimer(player); inv = Bukkit.createInventory(new GUISession(this, 1), getInventorySize(), getDisplayName(player)); for (Entry entry : buttons.entrySet()) { @@ -384,16 +358,13 @@ public void openInventory(Player player, int page) { if (page < 1) { throw new IllegalArgumentException("Page must be >= 1"); } - maxPage = InventoryPagination.getPageCount(getHighestSlot(), maxInvSize); int targetPage = Math.min(page, maxPage); cancelTimer(player); addPlaceholder("currentpage", String.valueOf(targetPage)); addPlaceholder("totalpages", String.valueOf(maxPage)); - inv = Bukkit.createInventory(new GUISession(this, targetPage), maxInvSize, getDisplayName(player)); this.page = targetPage; - int contentSize = InventoryPagination.getContentSize(maxInvSize); int startSlot = (targetPage - 1) * contentSize; for (Entry entry : buttons.entrySet()) { @@ -403,13 +374,11 @@ public void openInventory(Player player, int page) { } loadButton(player, entry.getValue(), sourceSlot, sourceSlot - startSlot); } - for (BInventoryButton button : pageButtons) { int displayedSlot = contentSize + button.getSlot(); inv.setItem(displayedSlot, button.getItem(player, placeholders)); button.setInv(this); } - loadNavigationItems(player); inv.setItem(contentSize, prevItem); inv.setItem(maxInvSize - 1, nextItem); @@ -436,33 +405,15 @@ public void setButtons(Map buttons) { this.buttons = buttons; } - /** - * Set whether button callbacks are dispatched asynchronously. This defaults to - * true for backwards compatibility with existing AdvancedCore GUI code. - * - * @param value true to run button callbacks asynchronously, false to run them - * on the inventory event thread - * @return this inventory - */ public BInventory setClickAsync(boolean value) { clickAsync = value; return this; } - /** - * Convenience method for opting this inventory into synchronous button callbacks. - * - * @return this inventory - */ public BInventory runClicksSync() { return setClickAsync(false); } - /** - * Convenience method for explicitly using the legacy asynchronous callback mode. - * - * @return this inventory - */ public BInventory runClicksAsync() { return setClickAsync(true); } @@ -573,7 +524,6 @@ private boolean hasPermission(Player player) { if (perm == null) { return true; } - if (!perm.contains("|")) { if (player.hasPermission(perm)) { return true; @@ -584,7 +534,7 @@ private boolean hasPermission(Player player) { return true; } } - + } player.sendMessage(MessageAPI.colorize(AdvancedCorePlugin.getInstance().getOptions().getFormatNoPerms())); return false; }