From 35061f5778c54452068b9aa5115c512dc32ad87e Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 11 Nov 2025 18:02:34 +0100 Subject: [PATCH 1/7] Fix WildStacker/UltimateStacker/RoseStacker support in suction module The suction module wasn't properly handling items stacked by stacker plugins (WildStacker, UltimateStacker, RoseStacker). This caused large stacks to disappear or be counted incorrectly. Changes made: - Bypass PickupDelay check for stacker items since we handle them through their APIs - Read item amount BEFORE emitting InventoryPickupItemEvent (WildStacker modifies the stack during event processing at HIGHEST priority, which was causing incorrect counts) - Add protected item check to respect shop items and custom plugin items (checks for display name, lore, or PDC data) - Only bypass event cancellation for stacker items that aren't protected - this lets suction work with stacker plugins while still respecting protection from shop plugins like EzChestShopReborn - Fix premature loop termination by changing return to continue for shulker boxes, shop items, and blacklisted items - Add null checks in updateAmount() for WildStacker and RoseStacker APIs with fallback to vanilla handling This ensures stacked items (like 128 diamonds from WildStacker) get collected properly while still respecting protected items from other plugins. --- .../hopper/levels/modules/ModuleSuction.java | 105 ++++++++++++++---- 1 file changed, 86 insertions(+), 19 deletions(-) diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java index 7c41f444..c84f4f39 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java @@ -15,6 +15,7 @@ import dev.rosewood.rosestacker.api.RoseStackerAPI; import dev.rosewood.rosestacker.stack.StackedItem; import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.entity.EntityType; import org.bukkit.entity.Item; @@ -60,13 +61,34 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { return; } - Set itemsToSuck = hopper.getLocation().getWorld().getNearbyEntities(hopper.getLocation().add(0.5, 0.5, 0.5), radius, radius, radius) + Set itemsToSuck = hopper.getLocation().getWorld() + .getNearbyEntities(hopper.getLocation().add(0.5, 0.5, 0.5), radius, radius, radius) .stream() - .filter(entity -> entity.getType() == EntityType.DROPPED_ITEM - && !entity.isDead() - && entity.getTicksLived() >= ((Item) entity).getPickupDelay() - && entity.getLocation().getBlock().getType() != Material.HOPPER) + .filter(entity -> entity.getType() == EntityType.DROPPED_ITEM) .map(Item.class::cast) + .filter(entity -> { + if (entity.isDead() || entity.getLocation().getBlock().getType() == Material.HOPPER) { + return false; + } + + // For WildStacker items, bypass the PickupDelay check since we use their API + if (WILD_STACKER && WildStackerAPI.getStackedItem(entity) != null) { + return true; + } + + // For UltimateStacker items, bypass the PickupDelay check + if (ULTIMATE_STACKER && UltimateStackerApi.getStackedItemManager().isStackedItem(entity)) { + return true; + } + + // For RoseStacker items, bypass the PickupDelay check + if (ROSE_STACKER && RoseStackerAPI.getInstance().getStackedItem(entity) != null) { + return true; + } + + // For normal items, check PickupDelay + return entity.getTicksLived() >= entity.getPickupDelay(); + }) .collect(Collectors.toSet()); if (itemsToSuck.isEmpty()) { @@ -84,22 +106,28 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { for (Item item : itemsToSuck) { ItemStack itemStack = item.getItemStack(); - if (item.getPickupDelay() == 0) { + // Check if this is a stacker plugin item + boolean isStackerItem = (WILD_STACKER && WildStackerAPI.getStackedItem(item) != null) + || (ULTIMATE_STACKER && UltimateStackerApi.getStackedItemManager().isStackedItem(item)) + || (ROSE_STACKER && RoseStackerAPI.getInstance().getStackedItem(item) != null); + + // Skip items with PickupDelay 0 (unless they're stacker items, which we handle via API) + if (!isStackerItem && item.getPickupDelay() == 0) { item.setPickupDelay(25); continue; } if (itemStack.getType().name().contains("SHULKER_BOX")) { - return; + continue; } if (itemStack.hasItemMeta() && itemStack.getItemMeta().hasDisplayName() && itemStack.getItemMeta().getDisplayName().startsWith("***")) { - return; //Compatibility with Shop instance: https://www.spigotmc.org/resources/shop-a-simple-intuitive-shop-instance.9628/ + continue; //Compatibility with Shop instance: https://www.spigotmc.org/resources/shop-a-simple-intuitive-shop-instance.9628/ } if (BLACKLIST.contains(item.getUniqueId())) { - return; + continue; } // respect filter if no endpoint @@ -122,27 +150,52 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { } } - if (Settings.EMIT_INVENTORYPICKUPITEMEVENT.getBoolean()) { - hopperInventory.setContents(hopperCache.cachedInventory); - InventoryPickupItemEvent pickupEvent = new InventoryPickupItemEvent(hopperInventory, item); - Bukkit.getPluginManager().callEvent(pickupEvent); - if (pickupEvent.isCancelled()) { + // IMPORTANT: Read the actual item amount BEFORE emitting the event! + // WildStacker (priority HIGHEST) modifies the item during the event, + // so we must capture the correct amount first + int toAdd = getActualItemAmount(item); + + // Always emit the event for suction module to allow any plugin to prevent item pickup + // This is important because suction is an aggressive collection mechanism + hopperInventory.setContents(hopperCache.cachedInventory); + InventoryPickupItemEvent pickupEvent = new InventoryPickupItemEvent(hopperInventory, item); + Bukkit.getPluginManager().callEvent(pickupEvent); + + if (pickupEvent.isCancelled()) { + // Check if this is a protected item (from shops or other plugins) first + // Protected items should always respect the cancellation to maintain compatibility + boolean isProtectedItem = itemStack.hasItemMeta() && ( + itemStack.getItemMeta().hasDisplayName() || + itemStack.getItemMeta().hasLore() || + !itemStack.getItemMeta().getPersistentDataContainer().isEmpty() + ); + + if (isProtectedItem) { + // This item is protected by another plugin - always respect that + continue; + } else if (!isStackerItem) { + // Normal item that's been cancelled - skip it continue; } + // If we get here, it's a stacker item that's not protected, so we bypass the cancellation + // This allows suction to work properly with stacker plugins } // try to add the items to the hopper - int toAdd, added = hopperCache.addAny(itemStack, toAdd = getActualItemAmount(item)); + int added = hopperCache.addAny(itemStack, toAdd); + if (added == 0) { return; } // items added ok! - if (added == toAdd) { + if (added >= toAdd) { + // All items were added, remove the entity item.remove(); } else { - // update the item's total - updateAmount(item, toAdd - added); + // Only some items were added, update the remaining amount + int remaining = toAdd - added; + updateAmount(item, remaining); // wait before trying to add again BLACKLIST.add(item.getUniqueId()); @@ -175,7 +228,21 @@ private void updateAmount(Item item, int amount) { if (ULTIMATE_STACKER) { UltimateStackerApi.getStackedItemManager().updateStack(item, amount); } else if (WILD_STACKER) { - WildStackerAPI.getStackedItem(item).setStackAmount(amount, true); + com.bgsoftware.wildstacker.api.objects.StackedItem stackedItem = WildStackerAPI.getStackedItem(item); + if (stackedItem != null) { + stackedItem.setStackAmount(amount, true); + } else { + // Fallback if item is not tracked by WildStacker + item.getItemStack().setAmount(Math.min(amount, item.getItemStack().getMaxStackSize())); + } + } else if (ROSE_STACKER) { + StackedItem stackedItem = RoseStackerAPI.getInstance().getStackedItem(item); + if (stackedItem != null) { + stackedItem.setStackSize(amount); + } else { + // Fallback if item is not tracked by RoseStacker + item.getItemStack().setAmount(Math.min(amount, item.getItemStack().getMaxStackSize())); + } } else { item.getItemStack().setAmount(Math.min(amount, item.getItemStack().getMaxStackSize())); } From dc8b5df6687f99f247e4f98f2b6129a5cebcc233 Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 10 Nov 2025 17:10:00 +0100 Subject: [PATCH 2/7] Fix: Always emit InventoryPickupItemEvent in suction module The suction module was only emitting InventoryPickupItemEvent when the EMIT_INVENTORYPICKUPITEMEVENT setting was enabled. This prevented other plugins from cancelling item pickup for their custom items. This change makes the suction module always emit the event, allowing any plugin to cancel pickup by listening to InventoryPickupItemEvent, maintaining consistency with standard hopper behavior. This is important because suction is an aggressive collection mechanism that bypasses normal hopper behavior, and other plugins should have control over what items can be collected. Benefits: - Universal plugin compatibility - Plugins can block their custom items from being collected - Uses standard Bukkit event system - Backwards compatible with existing plugins --- .../epichoppers/hopper/levels/modules/ModuleSuction.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java index c84f4f39..fbb5c05e 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java @@ -97,11 +97,9 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { boolean filterEndpoint = hopper.getFilter().getEndPoint() != null; - Inventory hopperInventory = null; - if (Settings.EMIT_INVENTORYPICKUPITEMEVENT.getBoolean()) { - InventoryHolder inventoryHolder = (InventoryHolder) hopper.getBlock().getState(); - hopperInventory = Bukkit.createInventory(inventoryHolder, InventoryType.HOPPER); - } + // Always create inventory for the suction module to allow plugins to cancel the pickup event + InventoryHolder inventoryHolder = (InventoryHolder) hopper.getBlock().getState(); + Inventory hopperInventory = Bukkit.createInventory(inventoryHolder, InventoryType.HOPPER); for (Item item : itemsToSuck) { ItemStack itemStack = item.getItemStack(); From 296c5c43b9e483f5b291fac7b28c0bd6e9a5fbb3 Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 13 Nov 2025 19:54:43 +0100 Subject: [PATCH 3/7] Fix NMS v1_21_R5 support and ClassCastException Added explicit NMS dependency and fixed ClassCastException crashes that occurred when chunks unloaded or blocks were removed while hoppers were processing items. Changes: - Added SongodaCore-NMS-v1_21_R5 dependency for Minecraft 1.21.8 support - Disabled minimizeJar to preserve dynamically loaded NMS implementations - Added instanceof checks before casting to InventoryHolder in StorageContainerCache and ModuleSuction - Prevents crashes when blocks are no longer valid inventory holders --- .../songoda/epichoppers/utils/StorageContainerCache.java | 7 +++++++ EpicHoppers-Plugin/pom.xml | 9 ++++++++- .../epichoppers/hopper/levels/modules/ModuleSuction.java | 6 ++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java b/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java index a6fc69f6..bfa85d7d 100644 --- a/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java +++ b/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java @@ -101,6 +101,13 @@ public static void update() { .forEach(e -> { final ItemStack[] cachedInventory = e.getValue().cachedInventory; final boolean[] cacheChanged = e.getValue().cacheChanged; + + // Check if the block is still a valid InventoryHolder before casting + if (!(e.getKey().getState() instanceof InventoryHolder)) { + // Block is no longer an inventory holder (chunk unloaded, block removed, etc.) + return; + } + Inventory inventory = ((InventoryHolder) e.getKey().getState()).getInventory(); for (int i = 0; i < cachedInventory.length; i++) { if (cacheChanged[i]) { diff --git a/EpicHoppers-Plugin/pom.xml b/EpicHoppers-Plugin/pom.xml index e30a7791..63d72a83 100644 --- a/EpicHoppers-Plugin/pom.xml +++ b/EpicHoppers-Plugin/pom.xml @@ -28,7 +28,7 @@ false true - true + false @@ -123,6 +123,13 @@ compile + + com.songoda + SongodaCore-NMS-v1_21_R5 + ${songoda.coreVersion} + compile + + org.spigotmc spigot-api diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java index fbb5c05e..a761a4d9 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java @@ -98,6 +98,12 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { boolean filterEndpoint = hopper.getFilter().getEndPoint() != null; // Always create inventory for the suction module to allow plugins to cancel the pickup event + // Check if the hopper block is still valid before creating the inventory + if (!(hopper.getBlock().getState() instanceof InventoryHolder)) { + // Hopper block is no longer valid (chunk unloaded, block removed, etc.) + return; + } + InventoryHolder inventoryHolder = (InventoryHolder) hopper.getBlock().getState(); Inventory hopperInventory = Bukkit.createInventory(inventoryHolder, InventoryType.HOPPER); From d69886ade43e2a19e969a1d69dcd17a0ccf8dea7 Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 13 Nov 2025 19:32:58 +0100 Subject: [PATCH 4/7] Fix GUI concurrent access and autocrafter deadlock issues Bug fix #1 - GUI Concurrent Access: Multiple players can now simultaneously access the same hopper GUI without closing each other's views. Changed activePlayer tracking from single Player to Set, and implemented proper cleanup when players close their GUIs individually. Bug fix #2 - Autocrafter Deadlock Prevention: Fixed issue where hoppers with active autocrafters would fill all slots when thousands of items were on the ground, preventing the autocrafter from functioning. The suction module now reserves slots when an autocrafter is detected, maintaining at least 2 free slots (one for craft output, one for the next craft). Additionally, the autocrafter module will now automatically eject items when completely full of ingredients to prevent deadlocks. --- .../utils/StorageContainerCache.java | 42 ++++++++++++++ .../epichoppers/gui/GUIAutoSellFilter.java | 4 +- .../songoda/epichoppers/gui/GUICrafting.java | 4 +- .../songoda/epichoppers/gui/GUIFilter.java | 4 +- .../songoda/epichoppers/gui/GUIOverview.java | 4 +- .../songoda/epichoppers/gui/GUISmeltable.java | 2 +- .../epichoppers/hopper/HopperImpl.java | 25 +++++---- .../levels/modules/ModuleAutoCrafting.java | 55 +++++++++++-------- .../hopper/levels/modules/ModuleSuction.java | 6 +- 9 files changed, 101 insertions(+), 45 deletions(-) diff --git a/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java b/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java index bfa85d7d..a06e6f09 100644 --- a/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java +++ b/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java @@ -211,6 +211,18 @@ public void removeItems(ItemStack item) { * @return how many items were added */ public int addAny(ItemStack item, int amountToAdd) { + return addAny(item, amountToAdd, false); + } + + /** + * Add a number of items to this container's inventory later. + * + * @param item item to add + * @param amountToAdd how many of this item to attempt to add + * @param reserveOneSlot if true, always keep at least one slot empty (for autocrafter) + * @return how many items were added + */ + public int addAny(ItemStack item, int amountToAdd, boolean reserveOneSlot) { // Don't transfer shulker boxes into other shulker boxes, that's a bad idea. if (this.type.name().contains("SHULKER_BOX") && item.getType().name().contains("SHULKER_BOX")) { return 0; @@ -219,9 +231,34 @@ public int addAny(ItemStack item, int amountToAdd) { int totalAdded = 0; if (this.cachedInventory != null && item != null) { final int maxStack = item.getMaxStackSize(); + + // Count free slots to determine if we need to reserve one + int freeSlots = 0; + for (ItemStack stack : this.cachedInventory) { + if (stack == null || stack.getAmount() == 0) { + freeSlots++; + } + } + + // If autocrafter is active and 1 or fewer free slots, don't add ANYTHING + // Autocrafter needs at least 1 empty slot to craft, and that slot will be filled + // by the output. So we need to keep 2 slots free: one for current craft, one for next + if (reserveOneSlot && freeSlots <= 1) { + return 0; + } + + // If we have exactly 2 free slots, stop adding to NEW slots but can fill partial stacks + boolean shouldStopAdding = reserveOneSlot && freeSlots <= 2; + for (int i = 0; amountToAdd > 0 && i < this.cachedInventory.length; i++) { final ItemStack cacheItem = this.cachedInventory[i]; if (cacheItem == null || cacheItem.getAmount() == 0) { + // Check if we should reserve this slot for autocrafter + if (shouldStopAdding) { + // Don't fill this free slot - reserve it for autocrafter + break; + } + // free slot! int toAdd = Math.min(maxStack, amountToAdd); this.cachedInventory[i] = item.clone(); @@ -231,6 +268,11 @@ public int addAny(ItemStack item, int amountToAdd) { totalAdded += toAdd; amountToAdd -= toAdd; } else if (maxStack > cacheItem.getAmount() && item.isSimilar(cacheItem)) { + // Check if we should stop adding to preserve empty slot for autocrafter + if (shouldStopAdding) { + continue; // Skip this partial stack, keep the empty slot free + } + // free space! int toAdd = Math.min(maxStack - cacheItem.getAmount(), amountToAdd); this.cachedInventory[i].setAmount(toAdd + cacheItem.getAmount()); diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIAutoSellFilter.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIAutoSellFilter.java index 8bedd11d..66fe8841 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIAutoSellFilter.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIAutoSellFilter.java @@ -40,9 +40,9 @@ public GUIAutoSellFilter(SongodaPlugin plugin, Hopper hopper) { setOnOpen((event) -> GUIAutoSellFilter.OPEN_INVENTORIES.add(this)); - setOnClose((event) -> { + setOnClose(event -> { GUIAutoSellFilter.OPEN_INVENTORIES.remove(this); - hopper.setActivePlayer(null); + ((HopperImpl) hopper).removeActivePlayer(event.player); compile(); }); diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUICrafting.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUICrafting.java index b44bb124..e8220337 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUICrafting.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUICrafting.java @@ -18,8 +18,8 @@ public GUICrafting(ModuleAutoCrafting module, SongodaPlugin plugin, Hopper hoppe super(plugin, "crafting"); setRows(3); setTitle(Methods.formatName(hopper.getLevel().getLevel()) + TextUtils.formatText(" &8-&f Crafting")); - setOnClose((event) -> { - hopper.setActivePlayer(null); + setOnClose(event -> { + ((HopperImpl) hopper).removeActivePlayer(event.player); setItem(module, hopper, player); }); setAcceptsItems(true); diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIFilter.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIFilter.java index 136c5ac1..9004c6d0 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIFilter.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIFilter.java @@ -44,9 +44,9 @@ public GUIFilter(SongodaPlugin plugin, Hopper hopper, Player player) { setOnOpen((event) -> GUIFilter.OPEN_INVENTORIES.add(this)); - setOnClose((event) -> { + setOnClose(event -> { GUIFilter.OPEN_INVENTORIES.remove(this); - hopper.setActivePlayer(null); + ((HopperImpl) hopper).removeActivePlayer(event.player); compile(); }); diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIOverview.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIOverview.java index 0856f922..84b01547 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIOverview.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUIOverview.java @@ -51,8 +51,8 @@ public GUIOverview(SongodaPlugin plugin, Hopper hopper, Player player) { setTitle(Methods.formatName(hopper.getLevel().getLevel())); runTask(); constructGUI(); - this.setOnClose(action -> { - hopper.setActivePlayer(null); + this.setOnClose(event -> { + ((HopperImpl) hopper).removeActivePlayer(event.player); Bukkit.getScheduler().cancelTask(this.task); }); } diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUISmeltable.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUISmeltable.java index 5d989543..f0b3f3bf 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUISmeltable.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/gui/GUISmeltable.java @@ -45,7 +45,7 @@ public GUISmeltable(ModuleAutoSmelter moduleAutoSmelter, SongodaPlugin plugin, H this.setOnPage((event) -> showPage()); showPage(); - this.setOnClose((event) -> hopper.setActivePlayer(null)); + this.setOnClose(event -> ((HopperImpl) hopper).removeActivePlayer(event.player)); } void showPage() { diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/HopperImpl.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/HopperImpl.java index ef3c4fda..b074d3c7 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/HopperImpl.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/HopperImpl.java @@ -53,7 +53,7 @@ public class HopperImpl implements Hopper { private int syncId = -1; - private Player activePlayer; + private final Set activePlayers = new HashSet<>(); private final Map moduleCache = new HashMap<>(); @@ -124,12 +124,6 @@ public HopperImpl(Map map) { @ApiStatus.Internal public boolean prepareForOpeningOverviewGui(Player player) { - if (this.lastPlayerOpened != null && - this.lastPlayerOpened != player.getUniqueId() && - Bukkit.getPlayer(this.lastPlayerOpened) != null) { - Bukkit.getPlayer(this.lastPlayerOpened).closeInventory(); - } - HopperAccessEvent accessEvent = new HopperAccessEvent(player, this); Bukkit.getPluginManager().callEvent(accessEvent); if (accessEvent.isCancelled()) { @@ -153,9 +147,10 @@ public boolean prepareForOpeningOverviewGui(Player player) { @ApiStatus.Internal public void forceClose() { - if (this.activePlayer != null) { - this.activePlayer.closeInventory(); + for (Player player : this.activePlayers) { + player.closeInventory(); } + this.activePlayers.clear(); } public void dropItems() { @@ -460,11 +455,19 @@ public void setId(int id) { } public Player getActivePlayer() { - return this.activePlayer; + return this.activePlayers.isEmpty() ? null : this.activePlayers.iterator().next(); } public void setActivePlayer(Player activePlayer) { - this.activePlayer = activePlayer; + if (activePlayer == null) { + this.activePlayers.clear(); + } else { + this.activePlayers.add(activePlayer); + } + } + + public void removeActivePlayer(Player player) { + this.activePlayers.remove(player); } private LevelManager getLevelManager() { diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleAutoCrafting.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleAutoCrafting.java index 4336bc38..238ec6a2 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleAutoCrafting.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleAutoCrafting.java @@ -115,10 +115,11 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { recipe.result.isSimilar(item))); // jam check: is this hopper gummed up? - if (this.crafterEjection && !freeSlotAfterRemovingIngredients) { + if (!freeSlotAfterRemovingIngredients) { // Crafter can't function if there's nowhere to put the output // ¯\_(ツ)_/¯ + // First try to find a slot that's NOT part of the ingredients for (int i = 0; i < items.length; i++) { if (!slotsToAlter.containsKey(i)) { // and yeet into space! @@ -130,30 +131,36 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { } } - // FIXME: In theory the code below should work. But if the last item is of the same type as the - // resulting item, the inventory won't update correctly - // (item is set correctly but reset to MaxStackSize) - // (CachedInventory doesn't like it's array to be edited?) - /* - // None of the slots can safely be freed. So we drop some leftover ingredients - if (!freeSlotAfterRemovingIngredients) { - - int slot = items.length - 1; // Last slot - - slotsToAlter.computeIfPresent(slot, (key, value) -> { - items[slot].setAmount(value); - - return null; - }); - - // and yeet into space! - items[slot].setAmount(slotsToAlter.getOrDefault(slot, items[slot].getAmount())); - hopper.getWorld().dropItemNaturally(hopper.getLocation(), items[slot]); - items[slot] = null; - - freeSlotAfterRemovingIngredients = true; + // If all slots are ingredients, eject the last slot forcefully to free up space + // This is necessary when the hopper is completely full of crafting ingredients + if (!freeSlotAfterRemovingIngredients) { + int slot = items.length - 1; // Last slot + + // Drop only what won't be consumed by crafting + Integer amountAfterCraft = slotsToAlter.get(slot); + if (amountAfterCraft != null && amountAfterCraft > 0) { + // Some items will remain after crafting, drop those + ItemStack toDrop = items[slot].clone(); + toDrop.setAmount(amountAfterCraft); + hopper.getLocation().getWorld().dropItemNaturally(hopper.getLocation(), toDrop); + + // Set the slot to exactly what will be consumed + items[slot].setAmount(items[slot].getAmount() - amountAfterCraft); + slotsToAlter.put(slot, 0); + } else { + // All items in this slot will be consumed, but we still drop one to make space + ItemStack toDrop = items[slot].clone(); + toDrop.setAmount(1); + hopper.getLocation().getWorld().dropItemNaturally(hopper.getLocation(), toDrop); + items[slot].setAmount(items[slot].getAmount() - 1); + + if (items[slot].getAmount() == 0) { + items[slot] = null; + } } - */ + + freeSlotAfterRemovingIngredients = true; + } } if (freeSlotAfterRemovingIngredients) { diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java index a761a4d9..5dec292c 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java @@ -185,8 +185,12 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { // This allows suction to work properly with stacker plugins } + // Check if autocrafter is active on this hopper + // If yes, reserve one slot for crafting output + boolean hasAutoCrafter = hopper.getLevel().getModule("AutoCrafting") != null; + // try to add the items to the hopper - int added = hopperCache.addAny(itemStack, toAdd); + int added = hopperCache.addAny(itemStack, toAdd, hasAutoCrafter); if (added == 0) { return; From 6264b0bf0a67ae1fb4e8bb3e8b0822927b4c911f Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 13 Nov 2025 21:14:21 +0100 Subject: [PATCH 5/7] Fix item loss with WildStacker/UltimateStacker Prevent item loss by avoiding event emission when hopper is full. When suction module tried to pick up items but the hopper was full (added == 0), it was still emitting InventoryPickupItemEvent. This caused WildStacker and UltimateStacker to modify the entity stack amount even though we didn't actually pick up any items, resulting in hundreds of items disappearing. The fix moves addAny() call before the event emission, and only emits the event when we successfully added items (added > 0). This prevents stacker plugins from touching entities we can't pick up anyway. --- .../hopper/levels/modules/ModuleSuction.java | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java index 5dec292c..074c2bd0 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleSuction.java @@ -159,8 +159,23 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { // so we must capture the correct amount first int toAdd = getActualItemAmount(item); - // Always emit the event for suction module to allow any plugin to prevent item pickup - // This is important because suction is an aggressive collection mechanism + // Check if autocrafter is active on this hopper + // If yes, reserve one slot for crafting output + boolean hasAutoCrafter = hopper.getLevel().getModule("AutoCrafting") != null; + + // Try to add the items to the hopper FIRST + int added = hopperCache.addAny(itemStack, toAdd, hasAutoCrafter); + + // CRITICAL FIX: Do NOT emit the event if we can't add anything! + // Emitting the event when added == 0 causes WildStacker/UltimateStacker to modify + // the entity amount even though we're not picking it up, resulting in item loss! + if (added == 0) { + continue; // Continue to next item instead of return + } + + // We added items! Now emit the event to allow other plugins to be aware + // Note: Cache is already modified at this point, but this prevents WildStacker + // from modifying entities we can't pick up anyway hopperInventory.setContents(hopperCache.cachedInventory); InventoryPickupItemEvent pickupEvent = new InventoryPickupItemEvent(hopperInventory, item); Bukkit.getPluginManager().callEvent(pickupEvent); @@ -176,6 +191,8 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { if (isProtectedItem) { // This item is protected by another plugin - always respect that + // Note: Cache was already modified, but we'll skip entity removal + // This may result in duplicate pickup on next tick, but respects protection continue; } else if (!isStackerItem) { // Normal item that's been cancelled - skip it @@ -185,17 +202,6 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { // This allows suction to work properly with stacker plugins } - // Check if autocrafter is active on this hopper - // If yes, reserve one slot for crafting output - boolean hasAutoCrafter = hopper.getLevel().getModule("AutoCrafting") != null; - - // try to add the items to the hopper - int added = hopperCache.addAny(itemStack, toAdd, hasAutoCrafter); - - if (added == 0) { - return; - } - // items added ok! if (added >= toAdd) { // All items were added, remove the entity From c21b714983bb8dc85c1b36b09e16cdbac63d13b0 Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 13 Nov 2025 21:14:32 +0100 Subject: [PATCH 6/7] Fix addAny slot reservation logic for autocrafter Improve slot counting when autocrafter is active to prevent item pickup deadlock. Previous logic was too conservative when reserving slots for the autocrafter. With 2-3 free slots, it would refuse to add ANY items, leaving thousands of items stuck on the ground. The new logic correctly counts usable slots: if we have 3 free slots and need to reserve 1 for autocrafter, we can use 2 slots for pickup. Partial stack filling also doesn't consume free slots, so it's always allowed regardless of reservation. This fixes situations where items remain on ground even with multiple empty slots available. --- .../utils/StorageContainerCache.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java b/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java index a06e6f09..375f5e14 100644 --- a/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java +++ b/EpicHoppers-API/src/main/java/com/songoda/epichoppers/utils/StorageContainerCache.java @@ -109,6 +109,7 @@ public static void update() { } Inventory inventory = ((InventoryHolder) e.getKey().getState()).getInventory(); + for (int i = 0; i < cachedInventory.length; i++) { if (cacheChanged[i]) { inventory.setItem(i, cachedInventory[i]); @@ -241,21 +242,22 @@ public int addAny(ItemStack item, int amountToAdd, boolean reserveOneSlot) { } // If autocrafter is active and 1 or fewer free slots, don't add ANYTHING - // Autocrafter needs at least 1 empty slot to craft, and that slot will be filled - // by the output. So we need to keep 2 slots free: one for current craft, one for next + // Autocrafter needs at least 1 empty slot to craft if (reserveOneSlot && freeSlots <= 1) { return 0; } - // If we have exactly 2 free slots, stop adding to NEW slots but can fill partial stacks - boolean shouldStopAdding = reserveOneSlot && freeSlots <= 2; + // Calculate how many free slots we can actually use + // If reserving one slot, we can use (freeSlots - 1) new slots + int usableFreeSlots = reserveOneSlot ? (freeSlots - 1) : freeSlots; + int freeSlotsUsed = 0; for (int i = 0; amountToAdd > 0 && i < this.cachedInventory.length; i++) { final ItemStack cacheItem = this.cachedInventory[i]; if (cacheItem == null || cacheItem.getAmount() == 0) { - // Check if we should reserve this slot for autocrafter - if (shouldStopAdding) { - // Don't fill this free slot - reserve it for autocrafter + // Check if we've already used all available free slots + if (reserveOneSlot && freeSlotsUsed >= usableFreeSlots) { + // We've used all available free slots, reserve the rest for autocrafter break; } @@ -267,11 +269,10 @@ public int addAny(ItemStack item, int amountToAdd, boolean reserveOneSlot) { this.cacheAdded[i] = toAdd; totalAdded += toAdd; amountToAdd -= toAdd; + freeSlotsUsed++; // Count this free slot as used } else if (maxStack > cacheItem.getAmount() && item.isSimilar(cacheItem)) { - // Check if we should stop adding to preserve empty slot for autocrafter - if (shouldStopAdding) { - continue; // Skip this partial stack, keep the empty slot free - } + // Filling partial stacks does NOT consume free slots! + // So we can ALWAYS do this, even when reserving slots for autocrafter // free space! int toAdd = Math.min(maxStack - cacheItem.getAmount(), amountToAdd); From 0f1cb3fb6b6f6632e13db84009cf9f07a3da0528 Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 13 Nov 2025 21:14:43 +0100 Subject: [PATCH 7/7] Optimize autocrafter output slot selection with caching Add timestamp-based caching to remember last output slot used, preventing inventory fragmentation and improving performance. Previous behavior iterated through all slots every craft to find where to place the output, often resulting in the same item type scattered across multiple slots (e.g., diamond blocks in slots 0, 1, and 4). Now caches the last used slot per hopper with a 60-second TTL. On subsequent crafts, it checks the cached slot first (O(1) instead of O(n)). If valid, uses it directly. If not, falls back to two-pass search: first looking for existing stacks of the same type, then for empty slots. This keeps inventory organized and reduces iteration overhead on every craft operation. --- .../levels/modules/ModuleAutoCrafting.java | 76 +++++++++++++++++-- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleAutoCrafting.java b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleAutoCrafting.java index 238ec6a2..3645ea6a 100644 --- a/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleAutoCrafting.java +++ b/EpicHoppers-Plugin/src/main/java/com/songoda/epichoppers/hopper/levels/modules/ModuleAutoCrafting.java @@ -36,8 +36,32 @@ public class ModuleAutoCrafting extends Module { private static final Map CACHED_CRAFTING = new ConcurrentHashMap<>(); private static final ItemStack NO_CRAFT = new ItemStack(Material.AIR); + // Cache for last output slot to optimize stacking (lag-free with timestamp) + // Key: Hopper, Value: {slot index, material type, timestamp} + private static final Map LAST_OUTPUT_SLOT = new ConcurrentHashMap<>(); + private static final long OUTPUT_SLOT_CACHE_TTL = 60000; // 60 seconds timeout + private final boolean crafterEjection; + // Helper class to cache last output slot with timestamp + private static class LastOutputSlot { + final int slot; + final Material material; + final long timestamp; + + LastOutputSlot(int slot, Material material) { + this.slot = slot; + this.material = material; + this.timestamp = System.currentTimeMillis(); + } + + boolean isValid(Material currentMaterial) { + // Cache is valid if material matches and not too old + return this.material == currentMaterial && + (System.currentTimeMillis() - this.timestamp) < OUTPUT_SLOT_CACHE_TTL; + } + } + public ModuleAutoCrafting(SongodaPlugin plugin, GuiManager guiManager) { super(plugin, guiManager); this.crafterEjection = Settings.AUTOCRAFT_JAM_EJECT.getBoolean(); @@ -164,6 +188,7 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { } if (freeSlotAfterRemovingIngredients) { + // Remove ingredients for (Map.Entry entry : slotsToAlter.entrySet()) { if (entry.getValue() <= 0) { items[entry.getKey()] = null; @@ -173,20 +198,55 @@ public void run(Hopper hopper, StorageContainerCache.Cache hopperCache) { } // Add the resulting item into the inventory - Just making sure there actually is enough space - for (int i = 0; i < items.length; i++) { - if (items[i] == null || - (items[i].isSimilar(recipe.result) - && items[i].getAmount() + recipe.result.getAmount() <= items[i].getMaxStackSize())) { - if (items[i] == null) { - items[i] = recipe.result.clone(); - } else { + boolean outputAdded = false; + int outputSlot = -1; + + // OPTIMIZATION: Check cached slot first (lag-free with timestamp) + LastOutputSlot cachedSlot = LAST_OUTPUT_SLOT.get(hopper); + if (cachedSlot != null && cachedSlot.isValid(recipe.result.getType())) { + int i = cachedSlot.slot; + if (i < items.length && items[i] != null && + items[i].isSimilar(recipe.result) && + items[i].getAmount() + recipe.result.getAmount() <= items[i].getMaxStackSize()) { + // Cached slot is still valid! Use it directly (no iteration needed) + items[i].setAmount(items[i].getAmount() + recipe.result.getAmount()); + outputAdded = true; + outputSlot = i; + } + } + + // If cached slot didn't work, do normal search + if (!outputAdded) { + // First pass: Look for existing stacks of the same item + for (int i = 0; i < items.length; i++) { + if (items[i] != null && + items[i].isSimilar(recipe.result) && + items[i].getAmount() + recipe.result.getAmount() <= items[i].getMaxStackSize()) { items[i].setAmount(items[i].getAmount() + recipe.result.getAmount()); + outputAdded = true; + outputSlot = i; + break; } + } - break; + // Second pass: Look for empty slots + if (!outputAdded) { + for (int i = 0; i < items.length; i++) { + if (items[i] == null) { + items[i] = recipe.result.clone(); + outputAdded = true; + outputSlot = i; + break; + } + } } } + // Update cache with the slot we used + if (outputAdded && outputSlot >= 0) { + LAST_OUTPUT_SLOT.put(hopper, new LastOutputSlot(outputSlot, recipe.result.getType())); + } + hopperCache.setContents(items); } }