diff --git a/build.gradle.kts b/build.gradle.kts index deadeb71..42fa3468 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ allprojects { apply(plugin = "maven-publish") group = "github.nighter" - version = "1.6.7" + version = "1.6.7-DEV" repositories { mavenCentral() diff --git a/core/src/main/java/github/nighter/smartspawner/extras/HopperTransfer.java b/core/src/main/java/github/nighter/smartspawner/extras/HopperTransfer.java index 5d0ef16b..663beb4c 100644 --- a/core/src/main/java/github/nighter/smartspawner/extras/HopperTransfer.java +++ b/core/src/main/java/github/nighter/smartspawner/extras/HopperTransfer.java @@ -6,6 +6,7 @@ import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.spawner.properties.VirtualInventory; import github.nighter.smartspawner.utils.BlockPos; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; @@ -64,36 +65,47 @@ private void transferItems(Location hopperLoc, Location spawnerLoc) { var state = hopperLoc.getBlock().getState(false); if (!(state instanceof Hopper hopper)) return; - Map displayItems = virtualInv.getDisplayInventory(); - if (displayItems == null || displayItems.isEmpty()) return; - Inventory hopperInv = hopper.getInventory(); int transferred = 0; - + int rangeStart = 0; + int rangeSize = Math.max(plugin.getHopperConfig().getStackPerTransfer(), 9); List removed = new ArrayList<>(); - for (ItemStack item : displayItems.values()) { - if (transferred >= plugin.getHopperConfig().getStackPerTransfer()) break; - if (item == null || item.getType() == Material.AIR) continue; + while (transferred < plugin.getHopperConfig().getStackPerTransfer()) { + Int2ObjectMap displayItems = virtualInv.getDisplayRange(rangeStart, rangeSize); + if (displayItems.isEmpty()) { + break; + } + + for (ItemStack item : displayItems.values()) { + if (transferred >= plugin.getHopperConfig().getStackPerTransfer()) { + break; + } + if (item == null || item.getType() == Material.AIR) { + continue; + } - ItemStack clone = item.clone(); - int originalAmount = clone.getAmount(); + ItemStack clone = item.clone(); + int originalAmount = clone.getAmount(); - HashMap leftovers = hopperInv.addItem(clone); + HashMap leftovers = hopperInv.addItem(clone); - int insertedAmount = originalAmount; + int insertedAmount = originalAmount; - if (!leftovers.isEmpty()) { - insertedAmount -= leftovers.values().iterator().next().getAmount(); - } + if (!leftovers.isEmpty()) { + insertedAmount -= leftovers.values().iterator().next().getAmount(); + } - if (insertedAmount > 0) { - ItemStack toRemove = item.clone(); - toRemove.setAmount(insertedAmount); - removed.add(toRemove); - transferred++; + if (insertedAmount > 0) { + ItemStack toRemove = item.clone(); + toRemove.setAmount(insertedAmount); + removed.add(toRemove); + transferred++; + } } + + rangeStart += rangeSize; } if (!removed.isEmpty()) { diff --git a/core/src/main/java/github/nighter/smartspawner/language/LanguageManager.java b/core/src/main/java/github/nighter/smartspawner/language/LanguageManager.java index 455e9a25..bec8ad43 100644 --- a/core/src/main/java/github/nighter/smartspawner/language/LanguageManager.java +++ b/core/src/main/java/github/nighter/smartspawner/language/LanguageManager.java @@ -1,6 +1,7 @@ package github.nighter.smartspawner.language; import github.nighter.smartspawner.SmartSpawner; +import github.nighter.smartspawner.utils.LRUCache; import lombok.Getter; import org.bukkit.ChatColor; import org.bukkit.Material; @@ -17,7 +18,6 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; -import java.text.Normalizer; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerFileHandler.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerFileHandler.java index 968df9b8..af75770c 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerFileHandler.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerFileHandler.java @@ -437,13 +437,7 @@ private SpawnerData loadSpawnerFromConfig(String spawnerId, boolean logErrors, b int amount = entry.getValue(); if (item != null && amount > 0) { - while (amount > 0) { - int batchSize = Math.min(amount, item.getMaxStackSize()); - ItemStack batch = item.clone(); - batch.setAmount(batchSize); - virtualInv.addItems(Collections.singletonList(batch)); - amount -= batchSize; - } + virtualInv.addItem(item, amount); } } } catch (Exception e) { diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java index d1e0f00d..20fcd2ff 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java @@ -680,13 +680,7 @@ private void loadInventoryFromJson(String jsonData, VirtualInventory virtualInv) int amount = entry.getValue(); if (item != null && amount > 0) { - while (amount > 0) { - int batchSize = Math.min(amount, item.getMaxStackSize()); - ItemStack batch = item.clone(); - batch.setAmount(batchSize); - virtualInv.addItems(Collections.singletonList(batch)); - amount -= batchSize; - } + virtualInv.addItem(item, amount); } } } catch (Exception e) { diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/gui/storage/SpawnerStorageUI.java b/core/src/main/java/github/nighter/smartspawner/spawner/gui/storage/SpawnerStorageUI.java index 321db3f3..47825d06 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/gui/storage/SpawnerStorageUI.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/gui/storage/SpawnerStorageUI.java @@ -7,13 +7,16 @@ import github.nighter.smartspawner.spawner.gui.layout.GuiButton; import github.nighter.smartspawner.spawner.gui.layout.GuiLayout; import github.nighter.smartspawner.spawner.gui.layout.GuiLayoutConfig; +import github.nighter.smartspawner.spawner.gui.storage.button.NavigationButtonCache; +import github.nighter.smartspawner.spawner.gui.storage.button.SortButton; import github.nighter.smartspawner.spawner.lootgen.loot.EntityLootConfig; import github.nighter.smartspawner.spawner.lootgen.loot.LootItem; import github.nighter.smartspawner.spawner.properties.ItemSignature; import github.nighter.smartspawner.spawner.properties.VirtualInventory; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.Scheduler; -import github.nighter.smartspawner.Scheduler.Task; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import lombok.Getter; import net.kyori.adventure.text.Component; import org.bukkit.inventory.Inventory; @@ -24,7 +27,6 @@ import org.bukkit.Material; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; public class SpawnerStorageUI { @@ -38,19 +40,11 @@ public class SpawnerStorageUI { // Precomputed buttons to avoid repeated creation private final Map staticButtons; - // Lightweight caches with better eviction strategies - private final Map navigationButtonCache; - private final Map pageIndicatorCache; - - // Cache expiry time reduced for more responsive updates - private static final int MAX_CACHE_SIZE = 100; + private final NavigationButtonCache navigationButtons; // Cache for title format to avoid repeated language lookups private String cachedStorageTitleFormat = null; - // Cleanup task to remove stale entries from caches - private Task cleanupTask; - public SpawnerStorageUI(SmartSpawner plugin) { this.plugin = plugin; this.languageManager = plugin.getLanguageManager(); @@ -58,11 +52,10 @@ public SpawnerStorageUI(SmartSpawner plugin) { // Initialize caches with appropriate initial capacity this.staticButtons = new HashMap<>(8); - this.navigationButtonCache = new ConcurrentHashMap<>(16); - this.pageIndicatorCache = new ConcurrentHashMap<>(16); + this.navigationButtons = new NavigationButtonCache(data -> createButton(data.material(), data.name(), data.lore())); initializeStaticButtons(); - startCleanupTask(); + initializeNavigationButtons(); } public void reload() { @@ -70,13 +63,14 @@ public void reload() { this.layoutConfig = plugin.getGuiLayoutConfig(); // Clear caches to force reloading of buttons - navigationButtonCache.clear(); - pageIndicatorCache.clear(); staticButtons.clear(); cachedStorageTitleFormat = null; // Reinitialize static buttons initializeStaticButtons(); + + // Reinitialize navigation buttons + initializeNavigationButtons(); } private void initializeStaticButtons() { @@ -234,7 +228,7 @@ public void updateDisplay(Inventory inventory, SpawnerData spawner, int page, in } // Track both changes and slots that need to be emptied - Map updates = new HashMap<>(); + Int2ObjectMap updates = new Int2ObjectOpenHashMap<>(); Set slotsToEmpty = new HashSet<>(); // Clear storage area slots first @@ -261,8 +255,8 @@ public void updateDisplay(Inventory inventory, SpawnerData spawner, int page, in } } - for (Map.Entry entry : updates.entrySet()) { - inventory.setItem(entry.getKey(), entry.getValue()); + for (Int2ObjectMap.Entry entry : updates.int2ObjectEntrySet()) { + inventory.setItem(entry.getIntKey(), entry.getValue()); } // Update hologram if enabled @@ -284,36 +278,30 @@ public void updateDisplay(Inventory inventory, SpawnerData spawner, int page, in } } - private void addPageItems(Map updates, Set slotsToEmpty, - SpawnerData spawner, int page) { + private void addPageItems(Map updates, Set slotsToEmpty, SpawnerData spawner, int page) { try { - // Get display items directly from VirtualInventory (source of truth) + // Read only the requested page instead of materializing the full logical inventory. VirtualInventory virtualInv = spawner.getVirtualInventory(); - Map displayItems = virtualInv.getDisplayInventory(); + Int2ObjectMap displayItems = virtualInv.getDisplayPage(page, StoragePageHolder.MAX_ITEMS_PER_PAGE); if (displayItems.isEmpty()) { return; } - // Calculate start index for current page - int startIndex = (page - 1) * StoragePageHolder.MAX_ITEMS_PER_PAGE; - - // Add items for this page - for (Map.Entry entry : displayItems.entrySet()) { - int globalIndex = entry.getKey(); - - // Check if item belongs on this page - if (globalIndex >= startIndex && globalIndex < startIndex + StoragePageHolder.MAX_ITEMS_PER_PAGE) { - int displaySlot = globalIndex - startIndex; - updates.put(displaySlot, entry.getValue()); - slotsToEmpty.remove(displaySlot); - } + for (Int2ObjectMap.Entry entry : displayItems.int2ObjectEntrySet()) { + int displaySlot = entry.getIntKey(); + updates.put(displaySlot, entry.getValue()); + slotsToEmpty.remove(displaySlot); } } finally { spawner.getInventoryLock().unlock(); } } + private void initializeNavigationButtons() { + navigationButtons.reload(layoutConfig.getCurrentStorageLayout(), languageManager); + } + private void addNavigationButtons(Map updates, SpawnerData spawner, int page, int totalPages) { if (totalPages == -1) { totalPages = calculateTotalPages(spawner); @@ -346,23 +334,29 @@ private void addNavigationButtons(Map updates, SpawnerData s switch (action) { case "previous_page": if (page > 1) { - String cacheKey = "prev-" + (page - 1); - item = navigationButtonCache.computeIfAbsent( - cacheKey, k -> createNavigationButton("previous", page - 1, button.getMaterial())); + item = navigationButtons.getPreviousButton(page - 1); } break; + case "next_page": if (page < totalPages) { - String cacheKey = "next-" + (page + 1); - item = navigationButtonCache.computeIfAbsent( - cacheKey, k -> createNavigationButton("next", page + 1, button.getMaterial())); + item = navigationButtons.getNextButton(page + 1); } break; case "take_all": item = staticButtons.get("takeAll"); break; case "sort_items": - item = createSortButton(spawner, button.getMaterial()); + item = SortButton.getOrBuildSortButton( + spawner, + button.getMaterial(), + languageManager, + data -> createButton( + data.material(), + data.name(), + data.lore() + ) + ); break; case "drop_page": item = staticButtons.get("dropPage"); @@ -435,25 +429,6 @@ private ItemStack createButton(Material material, String name, List lore return item; } - private ItemStack createNavigationButton(String type, int targetPage, Material material) { - Map placeholders = new HashMap<>(); - placeholders.put("target_page", String.valueOf(targetPage)); - - String buttonName; - String buttonKey; - - if (type.equals("previous")) { - buttonKey = "navigation_button_previous"; - } else { - buttonKey = "navigation_button_next"; - } - - buttonName = languageManager.getGuiItemName(buttonKey + ".name", placeholders); - String[] buttonLore = languageManager.getGuiItemLore(buttonKey + ".lore", placeholders); - - return createButton(material, buttonName, Arrays.asList(buttonLore)); - } - private ItemStack createSellButton(SpawnerData spawner, Material material) { // Create placeholders for total sell price Map placeholders = new HashMap<>(); @@ -493,48 +468,6 @@ private ItemStack createCollectExpButton(SpawnerData spawner, Material material) return createButton(material, name, lore); } - private ItemStack createSortButton(SpawnerData spawner, Material material) { - Map placeholders = new HashMap<>(); - - // Get current sort item - Material currentSort = spawner.getPreferredSortItem(); - - // Get format strings from configuration - String selectedItemFormat = languageManager.getGuiItemName("sort_items_button.selected_item"); - String unselectedItemFormat = languageManager.getGuiItemName("sort_items_button.unselected_item"); - String noneText = languageManager.getGuiItemName("sort_items_button.no_item"); - - // Get available items from spawner drops - StringBuilder availableItems = new StringBuilder(); - if (spawner.getLootConfig() != null && spawner.getLootConfig().getAllItems() != null) { - boolean first = true; - var sortedLoot = spawner.getLootConfig().getAllItems().stream() - .sorted(Comparator.comparing(item -> item.material().name())) - .toList(); - - for (var lootItem : sortedLoot) { - if (!first) availableItems.append("\n"); - String itemName = languageManager.getVanillaItemName(lootItem.material()); - String format = currentSort == lootItem.material() ? selectedItemFormat : unselectedItemFormat; - - // Replace {item_name} placeholder in format string - String formattedItem = format.replace("{item_name}", itemName); - availableItems.append(formattedItem); - first = false; - } - } - - if (availableItems.isEmpty()) { - availableItems.append(noneText); - } - - placeholders.put("available_items", availableItems.toString()); - - String name = languageManager.getGuiItemName("sort_items_button.name", placeholders); - List lore = languageManager.getGuiItemLoreWithMultilinePlaceholders("sort_items_button.lore", placeholders); - return createButton(material, name, lore); - } - private ItemStack createStorageSpawnerInfoButton(SpawnerData spawner, Material material) { Map storedItems = spawner.getVirtualInventory().getConsolidatedItems(); List lootComponents = buildStorageInfoLootComponents(spawner, storedItems); @@ -632,46 +565,11 @@ private List buildStorageInfoLootComponents(SpawnerData spawner, return components; } - private void startCleanupTask() { - cleanupTask = Scheduler.runTaskTimer(this::cleanupCaches, 20L * 30, 20L * 30); // Run every 30 seconds - } - - public void cancelTasks() { - if (cleanupTask != null) { - cleanupTask.cancel(); - cleanupTask = null; - } - } - - private void cleanupCaches() { - // LRU-like cleanup for navigation buttons - if (navigationButtonCache.size() > MAX_CACHE_SIZE) { - int toRemove = navigationButtonCache.size() - (MAX_CACHE_SIZE / 2); - List keysToRemove = new ArrayList<>(navigationButtonCache.keySet()); - for (int i = 0; i < Math.min(toRemove, keysToRemove.size()); i++) { - navigationButtonCache.remove(keysToRemove.get(i)); - } - } - - // LRU-like cleanup for page indicators - if (pageIndicatorCache.size() > MAX_CACHE_SIZE) { - int toRemove = pageIndicatorCache.size() - (MAX_CACHE_SIZE / 2); - List keysToRemove = new ArrayList<>(pageIndicatorCache.keySet()); - for (int i = 0; i < Math.min(toRemove, keysToRemove.size()); i++) { - pageIndicatorCache.remove(keysToRemove.get(i)); - } - } - } - public void cleanup() { - navigationButtonCache.clear(); - pageIndicatorCache.clear(); cachedStorageTitleFormat = null; - // Cancel scheduled tasks - cancelTasks(); - // Re-initialize static buttons (just in case language has changed) initializeStaticButtons(); + initializeNavigationButtons(); } } diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/gui/storage/button/NavigationButtonCache.java b/core/src/main/java/github/nighter/smartspawner/spawner/gui/storage/button/NavigationButtonCache.java new file mode 100644 index 00000000..05641c33 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/gui/storage/button/NavigationButtonCache.java @@ -0,0 +1,129 @@ +package github.nighter.smartspawner.spawner.gui.storage.button; + +import github.nighter.smartspawner.language.LanguageManager; +import github.nighter.smartspawner.spawner.gui.layout.GuiButton; +import github.nighter.smartspawner.spawner.gui.layout.GuiLayout; +import github.nighter.smartspawner.utils.LRUCache; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public final class NavigationButtonCache { + private static final int CACHE_SIZE = 512; + + private final LRUCache previousButtons = new LRUCache<>(CACHE_SIZE); + private final LRUCache nextButtons = new LRUCache<>(CACHE_SIZE); + private final Function buttonFactory; + + private String previousButtonName; + private String nextButtonName; + private List previousButtonLore = Collections.emptyList(); + private List nextButtonLore = Collections.emptyList(); + private Material previousButtonMaterial; + private Material nextButtonMaterial; + + public NavigationButtonCache(Function buttonFactory) { + this.buttonFactory = buttonFactory; + } + + public void reload(GuiLayout layout, LanguageManager languageManager) { + clear(); + previousButtonName = languageManager.getGuiItemName("navigation_button_previous.name"); + nextButtonName = languageManager.getGuiItemName("navigation_button_next.name"); + previousButtonLore = languageManager.getGuiItemLoreAsList("navigation_button_previous.lore"); + nextButtonLore = languageManager.getGuiItemLoreAsList("navigation_button_next.lore"); + previousButtonMaterial = null; + nextButtonMaterial = null; + + for (GuiButton button : layout.getAllButtons().values()) { + String action = getAnyActionFromButton(button); + if (action == null) { + continue; + } + + switch (action) { + case "previous_page" -> previousButtonMaterial = button.getMaterial(); + case "next_page" -> nextButtonMaterial = button.getMaterial(); + } + } + } + + public ItemStack getPreviousButton(int targetPage) { + return previousButtons.get(targetPage, this::createPreviousButton); + } + + public ItemStack getNextButton(int targetPage) { + return nextButtons.get(targetPage, this::createNextButton); + } + + public void clear() { + previousButtons.clear(); + nextButtons.clear(); + } + + private ItemStack createPreviousButton(int targetPage) { + return createButton(previousButtonMaterial, previousButtonName, previousButtonLore, targetPage); + } + + private ItemStack createNextButton(int targetPage) { + return createButton(nextButtonMaterial, nextButtonName, nextButtonLore, targetPage); + } + + private ItemStack createButton(Material material, String name, List lore, int targetPage) { + String targetPageText = String.valueOf(targetPage); + return buttonFactory.apply( + new ButtonData( + material, + replaceTargetPage(name, targetPageText), + replaceTargetPage(lore, targetPageText) + ) + ); + } + + private String replaceTargetPage(String text, String targetPage) { + return text != null ? text.replace("{target_page}", targetPage) : null; + } + + private List replaceTargetPage(List lore, String targetPage) { + if (lore.isEmpty()) { + return Collections.emptyList(); + } + + List replacedLore = new ArrayList<>(lore.size()); + for (String line : lore) { + replacedLore.add(line.replace("{target_page}", targetPage)); + } + return replacedLore; + } + + private String getAnyActionFromButton(GuiButton button) { + Map actions = button.getActions(); + if (actions == null || actions.isEmpty()) { + return null; + } + + String action = actions.get("click"); + if (action != null && !action.isEmpty()) { + return action; + } + + action = actions.get("left_click"); + if (action != null && !action.isEmpty()) { + return action; + } + + action = actions.get("right_click"); + if (action != null && !action.isEmpty()) { + return action; + } + + return null; + } + + public record ButtonData(Material material, String name, List lore) {} +} diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/gui/storage/button/SortButton.java b/core/src/main/java/github/nighter/smartspawner/spawner/gui/storage/button/SortButton.java new file mode 100644 index 00000000..bed4523c --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/gui/storage/button/SortButton.java @@ -0,0 +1,135 @@ +package github.nighter.smartspawner.spawner.gui.storage.button; + +import github.nighter.smartspawner.language.LanguageManager; +import github.nighter.smartspawner.utils.LRUCache; +import github.nighter.smartspawner.spawner.lootgen.loot.EntityLootConfig; +import github.nighter.smartspawner.spawner.lootgen.loot.LootItem; +import github.nighter.smartspawner.spawner.properties.SpawnerData; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.util.*; +import java.util.function.Function; + +public final class SortButton { + + private static final int SORT_BUTTON_CACHE_SIZE = 256; + private static final LRUCache SORT_BUTTON_CACHE = new LRUCache<>(SORT_BUTTON_CACHE_SIZE); + + private static final EnumMap MATERIAL_NAME_CACHE = new EnumMap<>(Material.class); + + private SortButton() {} + + public static ItemStack getOrBuildSortButton(SpawnerData spawner, Material buttonMaterial, + LanguageManager languageManager, Function buttonFactory) { + + EntityLootConfig lootConfig = spawner.getLootConfig(); + + return SORT_BUTTON_CACHE.get( + new SortButtonCacheKey( + lootConfig, + spawner.getPreferredSortItem(), + buttonMaterial + ), + key -> buildSortButton( + lootConfig, + key.selectedMaterial, + key.buttonMaterial, + languageManager, + buttonFactory + ) + ); + } + + private static ItemStack buildSortButton(EntityLootConfig lootConfig, Material currentSort, Material buttonMaterial, + LanguageManager languageManager, Function buttonFactory) { + + String selectedItemFormat = languageManager.getGuiItemName("sort_items_button.selected_item"); + String unselectedItemFormat = languageManager.getGuiItemName("sort_items_button.unselected_item"); + String noneText = languageManager.getGuiItemName("sort_items_button.no_item"); + + String availableItemsString; + + if (lootConfig != null && lootConfig.getAllItems() != null && !lootConfig.getAllItems().isEmpty()) { + + List sortedLoot = new ArrayList<>(lootConfig.getAllItems()); + + sortedLoot.sort(Comparator.comparing(item -> item.material().name())); + + StringBuilder availableItems = new StringBuilder(sortedLoot.size() * 32); + + boolean first = true; + + for (LootItem lootItem : sortedLoot) { + Material lootMaterial = lootItem.material(); + + if (!first) { + availableItems.append('\n'); + } + + String itemName = MATERIAL_NAME_CACHE.computeIfAbsent( + lootMaterial, + languageManager::getVanillaItemName + ); + + String format = currentSort == lootMaterial + ? selectedItemFormat + : unselectedItemFormat; + + availableItems.append(format.replace("{item_name}", itemName)); + + first = false; + } + + availableItemsString = availableItems.toString(); + } else { + availableItemsString = noneText; + } + + Map placeholders = new HashMap<>(1); + placeholders.put("available_items", availableItemsString); + + return buttonFactory.apply( + new ButtonData(buttonMaterial, + languageManager.getGuiItemName("sort_items_button.name", placeholders), + languageManager.getGuiItemLoreWithMultilinePlaceholders("sort_items_button.lore", placeholders) + ) + ); + } + + public record ButtonData(Material material, String name, List lore) {} + + private static final class SortButtonCacheKey { + private final EntityLootConfig lootConfig; + private final Material selectedMaterial; + private final Material buttonMaterial; + private final int hashCode; + + private SortButtonCacheKey(EntityLootConfig lootConfig, Material selectedMaterial, Material buttonMaterial) { + this.lootConfig = lootConfig; + this.selectedMaterial = selectedMaterial; + this.buttonMaterial = buttonMaterial; + + int hash = System.identityHashCode(lootConfig); + hash = 31 * hash + (selectedMaterial != null ? selectedMaterial.ordinal() : -1); + hash = 31 * hash + buttonMaterial.ordinal(); + + this.hashCode = hash; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SortButtonCacheKey other)) return false; + + return lootConfig == other.lootConfig + && selectedMaterial == other.selectedMaterial + && buttonMaterial == other.buttonMaterial; + } + + @Override + public int hashCode() { + return hashCode; + } + } +} diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/gui/synchronization/utils/LootPreGenerationHelper.java b/core/src/main/java/github/nighter/smartspawner/spawner/gui/synchronization/utils/LootPreGenerationHelper.java index e9fc095f..54c7c0c5 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/gui/synchronization/utils/LootPreGenerationHelper.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/gui/synchronization/utils/LootPreGenerationHelper.java @@ -2,11 +2,11 @@ import github.nighter.smartspawner.SmartSpawner; import github.nighter.smartspawner.Scheduler; +import github.nighter.smartspawner.spawner.properties.ItemSignature; import github.nighter.smartspawner.spawner.properties.SpawnerData; import org.bukkit.Location; -import org.bukkit.inventory.ItemStack; -import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; /** @@ -116,7 +116,7 @@ public void addPreGeneratedLootEarly(SpawnerData spawner, long cachedDelay) { } if (spawner.hasPreGeneratedLoot()) { - List items = spawner.getAndClearPreGeneratedItems(); + Map items = spawner.getAndClearPreGeneratedItems(); long exp = spawner.getAndClearPreGeneratedExperience(); // Add the loot with scheduled spawn time for accurate timer reset diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/LootResult.java b/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/LootResult.java index 38e7ff3e..3475d849 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/LootResult.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/LootResult.java @@ -1,8 +1,7 @@ package github.nighter.smartspawner.spawner.lootgen; -import org.bukkit.inventory.ItemStack; +import github.nighter.smartspawner.spawner.properties.ItemSignature; -import java.util.List; +import java.util.Map; -public record LootResult(List items, long experience) { -} \ No newline at end of file +public record LootResult(Map items, long experience) {} diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/SpawnerLootGenerator.java b/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/SpawnerLootGenerator.java index d19eaf05..1cac8e04 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/SpawnerLootGenerator.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/SpawnerLootGenerator.java @@ -15,7 +15,6 @@ import java.util.*; import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.atomic.AtomicInteger; public class SpawnerLootGenerator { private final SmartSpawner plugin; @@ -61,19 +60,17 @@ public void spawnLootToSpawner(SpawnerData spawner) { final long spawnTime; final int minMobs; final int maxMobs; - final AtomicInteger usedSlots; - final AtomicInteger maxSlots; try { // Timing is now managed by SpawnerRangeChecker (timer) and SpawnerGuiViewManager (spawn trigger) // No need for time check here since spawn is only called when timer expires // Get exact inventory slot usage - usedSlots = new AtomicInteger(spawner.getVirtualInventory().getUsedSlots()); - maxSlots = new AtomicInteger(spawner.getMaxSpawnerLootSlots()); + int usedSlots = spawner.getVirtualInventory().getUsedSlots(); + int maxSlots = spawner.getMaxSpawnerLootSlots(); // Check if both inventory and exp are full, only then skip loot generation - if (usedSlots.get() >= maxSlots.get() && spawner.getSpawnerExp() >= spawner.getMaxStoredExp()) { + if (usedSlots >= maxSlots && spawner.getSpawnerExp() >= spawner.getMaxStoredExp()) { if (!spawner.getIsAtCapacity()) { spawner.setIsAtCapacity(true); } @@ -127,24 +124,17 @@ public void spawnLootToSpawner(SpawnerData spawner) { } } - // Re-check max slots as it could have changed - maxSlots.set(spawner.getMaxSpawnerLootSlots()); - usedSlots.set(spawner.getVirtualInventory().getUsedSlots()); - - // Process items if there are any to add and inventory isn't completely full - if (!loot.items().isEmpty() && usedSlots.get() < maxSlots.get()) { - List itemsToAdd = new ArrayList<>(loot.items()); - - // Get exact calculation of slots with the new items - int totalRequiredSlots = calculateRequiredSlots(itemsToAdd, spawner.getVirtualInventory()); + if (!loot.items().isEmpty()) { + Map lootToAdd = loot.items(); + int maxSlots = spawner.getMaxSpawnerLootSlots(); - // If we'll exceed the limit, limit the items we're adding - if (totalRequiredSlots > maxSlots.get()) { - itemsToAdd = limitItemsToAvailableSlots(itemsToAdd, spawner); + int totalRequiredSlots = calculateRequiredSlots(lootToAdd, spawner.getVirtualInventory()); + if (totalRequiredSlots > maxSlots) { + lootToAdd = limitLootToAvailableSlots(lootToAdd, spawner); } - if (!itemsToAdd.isEmpty()) { - spawner.addItemsAndUpdateSellValue(itemsToAdd); + if (!lootToAdd.isEmpty()) { + spawner.addItemsAndUpdateSellValue(lootToAdd); changed = true; } } @@ -184,67 +174,53 @@ public void spawnLootToSpawner(SpawnerData spawner) { } public LootResult generateLoot(int minMobs, int maxMobs, SpawnerData spawner) { - int mobCount = ThreadLocalRandom.current().nextInt(maxMobs - minMobs + 1) + minMobs; long totalExperience = (long) spawner.getEntityExperienceValue() * mobCount; // Get valid items from the spawner's EntityLootConfig - List validItems = spawner.getValidLootItems(); + List validItems = spawner.getValidLootItems(); if (validItems.isEmpty()) { - return new LootResult(Collections.emptyList(), totalExperience); + return new LootResult(Collections.emptyMap(), totalExperience); } // Use a Map to consolidate identical drops instead of List - Map consolidatedLoot = new HashMap<>(); + Map consolidatedLoot = new HashMap<>(); + + boolean shouldApproximateLoot = Config.get().isApproximateLoot(); + int approximationThreshold = Config.get().getApproximationThreshold(); // Process mobs in batch rather than individually for (LootItem lootItem : validItems) { - // Calculate the probability for the entire mob batch at once int totalAmount; - if (Config.get().isApproximateLoot() && shouldApproximate(lootItem.chance(), mobCount)) { - // O(1) binomial approximation + if (shouldApproximateLoot && shouldApproximate(lootItem.chance(), mobCount, approximationThreshold)) { totalAmount = generateApproximatedLoot(lootItem, mobCount); } else { - // O(n) binomial distribution totalAmount = generateExactLoot(lootItem, mobCount); } - if (totalAmount > 0) { - // Create item just once per loot type - ItemStack prototype = lootItem.createItemStack(); - if (prototype != null) { - consolidatedLoot.merge(prototype, totalAmount, Integer::sum); - } + if (totalAmount <= 0) { + continue; } - } - // Convert consolidated map to item stacks - List finalLoot = new ArrayList<>(consolidatedLoot.size()); - for (Map.Entry entry : consolidatedLoot.entrySet()) { - ItemStack item = entry.getKey().clone(); - item.setAmount(Math.min(entry.getValue(), item.getMaxStackSize())); - finalLoot.add(item); - - // Handle amounts exceeding max stack size - int remaining = entry.getValue() - item.getMaxStackSize(); - while (remaining > 0) { - ItemStack extraStack = item.clone(); - extraStack.setAmount(Math.min(remaining, item.getMaxStackSize())); - finalLoot.add(extraStack); - remaining -= extraStack.getAmount(); + ItemStack prototype = lootItem.createItemStack(); + if (prototype == null || prototype.getType() == Material.AIR) { + continue; } + + ItemSignature signature = VirtualInventory.getSignature(prototype); + consolidatedLoot.merge(signature, totalAmount, Integer::sum); } - return new LootResult(finalLoot, totalExperience); + return new LootResult(consolidatedLoot, totalExperience); } // Determines whether to use expected-value approximation - private boolean shouldApproximate(double chance, int mobCount) { + private boolean shouldApproximate(double chance, int mobCount, int approximationThreshold) { // simple heuristic: use expected if at least threshold items can be generated if (chance <= 0D) return false; - return mobCount > (97.5D / chance) * Config.get().getApproximationThreshold(); + return mobCount > (97.5D / chance) * approximationThreshold; } // O(n) simulation: exact per-mob drop calculation @@ -275,99 +251,111 @@ private int generateApproximatedLoot(LootItem lootItem, int mobCount) { return (int) Math.round(expectedDrops * avgAmount * jitter); } - private List limitItemsToAvailableSlots(List items, SpawnerData spawner) { - VirtualInventory currentInventory = spawner.getVirtualInventory(); + private Map limitLootToAvailableSlots(Map loot, SpawnerData spawner) { + VirtualInventory inventory = spawner.getVirtualInventory(); + int maxSlots = spawner.getMaxSpawnerLootSlots(); - // If already full, return empty list - if (currentInventory.getUsedSlots() >= maxSlots) { - return Collections.emptyList(); + if (maxSlots <= 0) { + return Collections.emptyMap(); } - // Create a simulation inventory - Map simulatedInventory = new HashMap<>(currentInventory.getConsolidatedItems()); - List acceptedItems = new ArrayList<>(); + Map simulatedInventory = new HashMap<>(inventory.getConsolidatedItems()); + Map acceptedLoot = new HashMap<>(loot.size()); - // Sort items by priority (you can change this sorting strategy) - items.sort(Comparator.comparing(item -> item.getType().name())); + int usedSlots = calculateSlots(simulatedInventory); - for (ItemStack item : items) { - if (item == null || item.getAmount() <= 0) continue; + List> entries = new ArrayList<>(loot.entrySet()); - // Add to simulation and check slot count - Map tempSimulation = new HashMap<>(simulatedInventory); - // Use cached signature to avoid excessive cloning - ItemSignature sig = VirtualInventory.getSignature(item); - tempSimulation.merge(sig, (long) item.getAmount(), (a, b) -> a + b); + entries.sort(Comparator.comparing(entry -> entry.getKey().getMaterial().name())); - // Calculate slots needed - int slotsNeeded = calculateSlots(tempSimulation); + for (Map.Entry entry : entries) { + ItemSignature signature = entry.getKey(); - // If we still have room, accept this item - if (slotsNeeded <= maxSlots) { - acceptedItems.add(item); - simulatedInventory = tempSimulation; // Update simulation - } else { - // Try to accept a partial amount of this item - int maxStackSize = item.getMaxStackSize(); - long currentAmount = simulatedInventory.getOrDefault(sig, 0L); - - // Calculate how many we can add without exceeding slot limit - int remainingSlots = maxSlots - calculateSlots(simulatedInventory); - if (remainingSlots > 0) { - // Maximum items we can add in the remaining slots - long maxAddAmount = (long) remainingSlots * maxStackSize - (currentAmount % maxStackSize); - if (maxAddAmount > 0) { - // Create a partial item - ItemStack partialItem = item.clone(); - partialItem.setAmount((int) Math.min(maxAddAmount, item.getAmount())); - acceptedItems.add(partialItem); - - // Update simulation - simulatedInventory.merge(sig, (long) partialItem.getAmount(), (a, b) -> a + b); - } - } + int amount = entry.getValue(); + + int maxStackSize = signature.getMaxStackSize(); + + long currentAmount = simulatedInventory.getOrDefault(signature, 0L); + + int oldSlots = slotsFor(currentAmount, maxStackSize); + int newSlots = slotsFor(currentAmount + amount, maxStackSize); + + int slotDelta = newSlots - oldSlots; + + if (usedSlots + slotDelta <= maxSlots) { + acceptedLoot.put(signature, amount); + + simulatedInventory.put(signature, currentAmount + amount); + + usedSlots += slotDelta; + + continue; + } + + int remainingSlots = Math.max(0, maxSlots - usedSlots); + long maxAddAmount = ((long) (oldSlots + remainingSlots) * maxStackSize) - currentAmount; + + if (maxAddAmount <= 0) { + continue; + } + + int acceptedAmount = (int) Math.min(maxAddAmount, amount); - // We've filled all slots, stop processing - break; + if (acceptedAmount > 0) { + acceptedLoot.put(signature, acceptedAmount); + simulatedInventory.put(signature, currentAmount + acceptedAmount); + usedSlots = calculateSlots(simulatedInventory); } } - return acceptedItems; + return acceptedLoot; } - private int calculateSlots(Map items) { - // Use a more efficient calculation approach - return items.entrySet().stream() - .mapToInt(entry -> { - long amount = entry.getValue(); - int maxStackSize = entry.getKey().getMaxStackSize(); - // Use integer division with ceiling function - return (int) ((amount + maxStackSize - 1) / maxStackSize); - }) - .sum(); + private int calculateRequiredSlots(Map loot, VirtualInventory inventory) { + Map simulatedItems = new HashMap<>(inventory.getConsolidatedItems()); + + for (Map.Entry entry : loot.entrySet()) { + simulatedItems.merge(entry.getKey(), (long) entry.getValue(), Long::sum); + } + + return calculateSlots(simulatedItems); } - private int calculateRequiredSlots(List items, VirtualInventory inventory) { - // Create a temporary map to simulate how items would stack - Map simulatedItems = new HashMap<>(); + private int calculateSlots(Map items) { + int total = 0; - // First, get existing items if we need to account for them - if (inventory != null) { - simulatedItems.putAll(inventory.getConsolidatedItems()); + for (Map.Entry entry : items.entrySet()) { + total += slotsFor(entry.getValue(), entry.getKey().getMaxStackSize()); } - // Add the new items to our simulation - for (ItemStack item : items) { - if (item == null || item.getAmount() <= 0) continue; + return total; + } - // Use cached signature to avoid excessive cloning - ItemSignature sig = VirtualInventory.getSignature(item); - simulatedItems.merge(sig, (long) item.getAmount(), (a, b) -> a + b); + private int slotsFor(long amount, int maxStackSize) { + if (amount <= 0) { + return 0; } - // Calculate exact slots needed - return calculateSlots(simulatedItems); + return (int) ((amount + maxStackSize - 1) / maxStackSize); + } + + private Map copyLoot(Map loot) { + if (loot == null || loot.isEmpty()) { + return Collections.emptyMap(); + } + + Map copy = new HashMap<>(loot.size()); + for (Map.Entry entry : loot.entrySet()) { + ItemSignature signature = entry.getKey(); + Integer amount = entry.getValue(); + if (signature == null || amount == null || amount <= 0) { + continue; + } + copy.merge(signature, amount, Integer::sum); + } + + return copy; } /** @@ -418,34 +406,31 @@ private void handleGuiUpdates(SpawnerData spawner) { */ public void preGenerateLoot(SpawnerData spawner, LootGenerationCallback callback) { if (!spawner.getLootGenerationLock().tryLock()) { - callback.onLootGenerated(Collections.emptyList(), 0); + callback.onLootGenerated(Collections.emptyMap(), 0); return; } try { try { if (!spawner.getDataLock().tryLock(50, java.util.concurrent.TimeUnit.MILLISECONDS)) { - callback.onLootGenerated(Collections.emptyList(), 0); + callback.onLootGenerated(Collections.emptyMap(), 0); return; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - callback.onLootGenerated(Collections.emptyList(), 0); + callback.onLootGenerated(Collections.emptyMap(), 0); return; } final int minMobs; final int maxMobs; - final boolean itemStorageFull; - try { int usedSlots = spawner.getVirtualInventory().getUsedSlots(); int maxSlots = spawner.getMaxSpawnerLootSlots(); - itemStorageFull = usedSlots >= maxSlots; - boolean atCapacity = itemStorageFull && spawner.getSpawnerExp() >= spawner.getMaxStoredExp(); + boolean atCapacity = usedSlots >= maxSlots && spawner.getSpawnerExp() >= spawner.getMaxStoredExp(); if (atCapacity) { - callback.onLootGenerated(Collections.emptyList(), 0); + callback.onLootGenerated(Collections.emptyMap(), 0); return; } @@ -456,15 +441,10 @@ public void preGenerateLoot(SpawnerData spawner, LootGenerationCallback callback } Scheduler.runTaskAsync(() -> { - LootResult loot; - if (itemStorageFull) { - loot = generateExperienceOnlyLoot(minMobs, maxMobs, spawner); - } else { - loot = generateLoot(minMobs, maxMobs, spawner); - } + LootResult loot = generateLoot(minMobs, maxMobs, spawner); callback.onLootGenerated( - loot.items() != null ? new ArrayList<>(loot.items()) : Collections.emptyList(), + copyLoot(loot.items()), loot.experience() ); }); @@ -473,13 +453,6 @@ public void preGenerateLoot(SpawnerData spawner, LootGenerationCallback callback } } - private LootResult generateExperienceOnlyLoot(int minMobs, int maxMobs, SpawnerData spawner) { - int mobCount = ThreadLocalRandom.current().nextInt(maxMobs - minMobs + 1) + minMobs; - long totalExperienceLong = (long) spawner.getEntityExperienceValue() * mobCount; - long totalExperience = Math.min(totalExperienceLong, Long.MAX_VALUE); - return new LootResult(Collections.emptyList(), totalExperience); - } - /** * Adds pre-generated loot to spawner instantly when timer expires. * @@ -495,10 +468,10 @@ private LootResult generateExperienceOnlyLoot(int minMobs, int maxMobs, SpawnerD *

Thread Safety: All Bukkit API calls are scheduled on main thread via Scheduler.runLocationTask * * @param spawner The spawner to add loot to - * @param items Pre-generated items list + * @param items Pre-generated items map * @param experience Pre-generated experience amount */ - public void addPreGeneratedLoot(SpawnerData spawner, List items, long experience) { + public void addPreGeneratedLoot(SpawnerData spawner, Map items, long experience) { addPreGeneratedLoot(spawner, items, experience, System.currentTimeMillis()); } @@ -507,11 +480,11 @@ public void addPreGeneratedLoot(SpawnerData spawner, List items, long * Used for early loot addition to prevent timer stutter. * * @param spawner The spawner to add loot to - * @param items Pre-generated items list + * @param items Pre-generated items map * @param experience Pre-generated experience amount * @param spawnTime The spawn time to set (for timer accuracy) */ - public void addPreGeneratedLoot(SpawnerData spawner, List items, long experience, long spawnTime) { + public void addPreGeneratedLoot(SpawnerData spawner, Map items, long experience, long spawnTime) { if ((items == null || items.isEmpty()) && experience == 0) { return; } @@ -564,29 +537,19 @@ public void addPreGeneratedLoot(SpawnerData spawner, List items, long } if (items != null && !items.isEmpty()) { - List validItems = new ArrayList<>(); - for (ItemStack item : items) { - if (item != null && item.getType() != Material.AIR) { - validItems.add(item.clone()); - } - } + Map lootToAdd = copyLoot(items); - if (!validItems.isEmpty()) { - int usedSlots = spawner.getVirtualInventory().getUsedSlots(); + if (!lootToAdd.isEmpty()) { int maxSlots = spawner.getMaxSpawnerLootSlots(); - if (usedSlots < maxSlots) { - List itemsToAdd = validItems; - - int totalRequiredSlots = calculateRequiredSlots(itemsToAdd, spawner.getVirtualInventory()); - if (totalRequiredSlots > maxSlots) { - itemsToAdd = limitItemsToAvailableSlots(itemsToAdd, spawner); - } + int totalRequiredSlots = calculateRequiredSlots(lootToAdd, spawner.getVirtualInventory()); + if (totalRequiredSlots > maxSlots) { + lootToAdd = limitLootToAvailableSlots(lootToAdd, spawner); + } - if (!itemsToAdd.isEmpty()) { - spawner.addItemsAndUpdateSellValue(itemsToAdd); - changed = true; - } + if (!lootToAdd.isEmpty()) { + spawner.addItemsAndUpdateSellValue(lootToAdd); + changed = true; } } } @@ -622,9 +585,9 @@ public interface LootGenerationCallback { /** * Called when loot generation completes. * - * @param items Generated items list (never null, may be empty) + * @param items Generated items map (never null, may be empty) * @param experience Generated experience amount */ - void onLootGenerated(List items, long experience); + void onLootGenerated(Map items, long experience); } } diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/SpawnerRangeChecker.java b/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/SpawnerRangeChecker.java index 8e3ce3e5..56f09655 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/SpawnerRangeChecker.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/SpawnerRangeChecker.java @@ -2,13 +2,13 @@ import github.nighter.smartspawner.SmartSpawner; import github.nighter.smartspawner.spawner.data.SpawnerManager; +import github.nighter.smartspawner.spawner.properties.ItemSignature; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.Scheduler; import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; import java.util.*; import java.util.concurrent.ExecutorService; @@ -207,7 +207,7 @@ private void checkAndSpawnLoot(SpawnerData spawner) { // Spawn loot (pre-generated if available, otherwise generate new) if (spawner.hasPreGeneratedLoot()) { - List items = spawner.getAndClearPreGeneratedItems(); + Map items = spawner.getAndClearPreGeneratedItems(); long exp = spawner.getAndClearPreGeneratedExperience(); plugin.getSpawnerLootGenerator().addPreGeneratedLoot(spawner, items, exp); } else { @@ -245,4 +245,3 @@ public void cleanup() { } } } - diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/properties/ItemSignature.java b/core/src/main/java/github/nighter/smartspawner/spawner/properties/ItemSignature.java index 9c20e3a3..3bf7edf4 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/properties/ItemSignature.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/properties/ItemSignature.java @@ -28,7 +28,6 @@ public ItemSignature(ItemStack item) { this.hashCode = calculateHashCode(meta); } - // Replace the current calculateHashCode() method with: private int calculateHashCode(ItemMeta meta) { // Use a faster hash algorithm and cache more item properties int result = 31 * this.material.ordinal(); // Using ordinal() instead of name() hashing diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/properties/SpawnerData.java b/core/src/main/java/github/nighter/smartspawner/spawner/properties/SpawnerData.java index 3ee6521e..ca6d32a6 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/properties/SpawnerData.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/properties/SpawnerData.java @@ -1,5 +1,6 @@ package github.nighter.smartspawner.spawner.properties; +import com.google.common.util.concurrent.AtomicDouble; import github.nighter.smartspawner.SmartSpawner; import github.nighter.smartspawner.commands.hologram.SpawnerHologram; import github.nighter.smartspawner.nms.VersionInitializer; @@ -15,6 +16,7 @@ import org.bukkit.inventory.meta.ItemMeta; import java.util.*; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; @@ -81,7 +83,7 @@ public class SpawnerData { // Calculated values based on stackSize @Getter private int maxStoragePages; - @Getter @Setter + @Getter private int maxSpawnerLootSlots; @Getter @Setter private long maxStoredExp; @@ -95,7 +97,7 @@ public class SpawnerData { @Getter @Setter private int maxStackSize; - @Getter @Setter + @Getter private VirtualInventory virtualInventory; @Getter private final Set filteredItems = new HashSet<>(); @@ -109,8 +111,7 @@ public class SpawnerData { private boolean lastSellProcessed; // Accumulated sell value for optimization - @Getter - private volatile double accumulatedSellValue; + private AtomicDouble accumulatedSellValue; @Getter private volatile boolean sellValueDirty; @@ -124,7 +125,7 @@ public class SpawnerData { private Material preferredSortItem; // CRITICAL: Pre-generated loot storage for better UX - access must be synchronized via lootGenerationLock - private volatile List preGeneratedItems; + private volatile Map preGeneratedItems; private volatile long preGeneratedExperience; private volatile boolean isPreGenerating; @@ -168,7 +169,7 @@ private void initializeDefaults() { this.stackSize = 1; this.lastSpawnTime = System.currentTimeMillis(); this.preferredSortItem = null; // Initialize sort preference as null - this.accumulatedSellValue = 0.0; + this.accumulatedSellValue = new AtomicDouble(0); this.sellValueDirty = true; } @@ -192,9 +193,7 @@ public void loadConfigurationValues() { public void recalculateAfterConfigReload() { calculateStackBasedValues(); - if (virtualInventory != null && virtualInventory.getMaxSlots() != maxSpawnerLootSlots) { - recreateVirtualInventory(); - } + // Mark sell value as dirty after config reload since prices may have changed this.sellValueDirty = true; updateHologramData(); @@ -214,9 +213,7 @@ public void recalculateAfterConfigReload() { */ public void recalculateAfterAPIModification() { calculateStackBasedValues(); - if (virtualInventory != null && virtualInventory.getMaxSlots() != maxSpawnerLootSlots) { - recreateVirtualInventory(); - } + updateHologramData(); // Invalidate GUI cache after API modifications @@ -231,12 +228,26 @@ public void recalculateAfterAPIModification() { private void calculateStackBasedValues() { this.maxStoredExp = clampToLong(baseMaxStoredExp * stackSize, 0L, Long.MAX_VALUE); this.maxStoragePages = clampToInt((long) baseMaxStoragePages * stackSize, 0, Integer.MAX_VALUE); - this.maxSpawnerLootSlots = clampToInt((long) maxStoragePages * 45L, 0, Integer.MAX_VALUE); + setMaxSpawnerLootSlots(clampToInt((long) maxStoragePages * 45L, 0, Integer.MAX_VALUE)); this.minMobs = clampToInt((long) baseMinMobs * stackSize, 0, Integer.MAX_VALUE); this.maxMobs = clampToInt((long) baseMaxMobs * stackSize, 0, Integer.MAX_VALUE); this.spawnerExp = clampToLong(this.spawnerExp, 0L, this.maxStoredExp); } + public void setMaxSpawnerLootSlots(int maxSpawnerLootSlots) { + this.maxSpawnerLootSlots = Math.max(0, maxSpawnerLootSlots); + if (virtualInventory != null) { + virtualInventory.setMaxSlots(this.maxSpawnerLootSlots); + } + } + + public void setVirtualInventory(VirtualInventory virtualInventory) { + this.virtualInventory = virtualInventory; + if (this.virtualInventory != null) { + this.virtualInventory.setMaxSlots(this.maxSpawnerLootSlots); + } + } + public void setSpawnDelay(long baseSpawnerDelay) { this.spawnDelay = baseSpawnerDelay > 0 ? baseSpawnerDelay : 500; long ticksWithBuffer = this.spawnDelay > Long.MAX_VALUE - 20L ? Long.MAX_VALUE : this.spawnDelay + 20L; @@ -323,9 +334,6 @@ private void updateStackSize(int newStackSize, boolean restartHopper) { this.stackSize = newStackSize; calculateStackBasedValues(); - // Resize the existing virtual inventory instead of creating a new one - virtualInventory.resize(this.maxSpawnerLootSlots); - // Reset lastSpawnTime to prevent exploit where players break spawners to trigger immediate loot this.lastSpawnTime = System.currentTimeMillis(); updateHologramData(); @@ -339,11 +347,6 @@ private void updateStackSize(int newStackSize, boolean restartHopper) { } } - private void recreateVirtualInventory() { - if (virtualInventory == null) return; - virtualInventory.resize(maxSpawnerLootSlots); - } - public void setSpawnerExp(long exp) { this.spawnerExp = Math.clamp(exp, 0L, maxStoredExp); updateHologramData(); @@ -375,6 +378,7 @@ private int clampToInt(long value, int min, int max) { return (int) value; } + // TODO: this does NOT work :cryo: private long clampToLong(long value, long min, long max) { if (value < min) { return min; @@ -552,26 +556,31 @@ public void markSellValueDirty() { this.sellValueDirty = true; } + public double getAccumulatedSellValue() { + return accumulatedSellValue.get(); + } + /** * Updates the accumulated sell value for specific items being added * @param itemsAdded Map of item signatures to quantities added * @param priceCache Price cache from loot config */ - public void incrementSellValue(Map itemsAdded, - Map priceCache) { + public void incrementSellValue(Map itemsAdded, Map priceCache) { if (itemsAdded == null || itemsAdded.isEmpty()) { return; } double addedValue = 0.0; - for (Map.Entry entry : itemsAdded.entrySet()) { + for (Map.Entry entry : itemsAdded.entrySet()) { double itemPrice = findItemPrice(entry.getKey(), priceCache); if (itemPrice > 0.0) { - addedValue += itemPrice * entry.getValue(); + addedValue += itemPrice * entry.getValue().longValue(); } } - this.accumulatedSellValue += addedValue; + if (addedValue > 0.0) { + this.accumulatedSellValue.addAndGet(addedValue); + } this.sellValueDirty = false; } @@ -585,24 +594,39 @@ public void decrementSellValue(List itemsRemoved, Map return; } - // Consolidate removed items Map consolidated = new java.util.HashMap<>(); for (ItemStack item : itemsRemoved) { if (item == null || item.getAmount() <= 0) continue; - // Use cached signature to avoid excessive cloning ItemSignature sig = VirtualInventory.getSignature(item); - consolidated.merge(sig, (long) item.getAmount(), (a, b) -> a + b); + consolidated.merge(sig, (long) item.getAmount(), Long::sum); + } + + decrementSellValue(consolidated, priceCache); + } + + /** + * Decrements the accumulated sell value when already-consolidated items are removed. + * @param itemsRemoved Map of item signatures to quantities removed + * @param priceCache Price cache from loot config + */ + public void decrementSellValue(Map itemsRemoved, Map priceCache) { + if (itemsRemoved == null || itemsRemoved.isEmpty()) { + return; } double removedValue = 0.0; - for (Map.Entry entry : consolidated.entrySet()) { + for (Map.Entry entry : itemsRemoved.entrySet()) { + if (entry.getKey() == null || entry.getValue() == null) { + continue; + } + double itemPrice = findItemPrice(entry.getKey(), priceCache); if (itemPrice > 0.0) { - removedValue += itemPrice * entry.getValue(); + removedValue += itemPrice * entry.getValue().longValue(); } } - this.accumulatedSellValue = Math.max(0.0, this.accumulatedSellValue - removedValue); + subtractAccumulatedSellValue(removedValue); } /** @@ -611,7 +635,7 @@ public void decrementSellValue(List itemsRemoved, Map */ public void recalculateSellValue() { if (lootConfig == null) { - this.accumulatedSellValue = 0.0; + this.accumulatedSellValue.set(0.0); this.sellValueDirty = false; return; } @@ -626,14 +650,27 @@ public void recalculateSellValue() { for (Map.Entry entry : items.entrySet()) { double itemPrice = findItemPrice(entry.getKey(), priceCache); if (itemPrice > 0.0) { - totalValue += itemPrice * entry.getValue(); + totalValue += itemPrice * entry.getValue().longValue(); } } - this.accumulatedSellValue = totalValue; + this.accumulatedSellValue.set(totalValue); this.sellValueDirty = false; } + private void subtractAccumulatedSellValue(double removedValue) { + if (removedValue <= 0.0) { + return; + } + + double current; + double updated; + do { + current = accumulatedSellValue.get(); + updated = Math.max(0.0, current - removedValue); + } while (!accumulatedSellValue.compareAndSet(current, updated)); + } + /** * Gets the price cache from loot config. * Prefers live prices from ItemPriceManager to avoid startup timing issues where @@ -720,12 +757,11 @@ private String createItemKey(ItemSignature itemSignature) { } /** - * Adds items to virtual inventory and updates accumulated sell value - * This is the preferred method to add items to maintain accurate sell value cache - * THREAD-SAFE: Uses inventoryLock to ensure atomicity - * @param items Items to add + * Adds already-consolidated items to virtual inventory and updates accumulated sell value. + * THREAD-SAFE: Uses inventoryLock to ensure atomicity. + * @param items Items to add, keyed by the same signature used by VirtualInventory */ - public void addItemsAndUpdateSellValue(List items) { + public void addItemsAndUpdateSellValue(Map items) { if (items == null || items.isEmpty()) { return; } @@ -733,22 +769,12 @@ public void addItemsAndUpdateSellValue(List items) { // CRITICAL: Acquire inventoryLock to ensure VirtualInventory remains source of truth inventoryLock.lock(); try { - // Consolidate items being added for efficient price lookup - Map itemsToAdd = new java.util.HashMap<>(); - for (ItemStack item : items) { - if (item == null || item.getAmount() <= 0) continue; - // Use cached signature to avoid excessive cloning - ItemSignature sig = VirtualInventory.getSignature(item); - itemsToAdd.merge(sig, (long) item.getAmount(), (a, b) -> a + b); - } - - // Add to VirtualInventory (source of truth) - this operation is atomic within the lock virtualInventory.addItems(items); // Update sell value atomically if (!sellValueDirty) { Map priceCache = createPriceCache(); - incrementSellValue(itemsToAdd, priceCache); + incrementSellValue(items, priceCache); } } finally { inventoryLock.unlock(); @@ -766,7 +792,27 @@ public boolean removeItemsAndUpdateSellValue(List items) { return true; } - // CRITICAL: Acquire inventoryLock to ensure VirtualInventory remains source of truth + Map itemsToRemove = new java.util.HashMap<>(); + for (ItemStack item : items) { + if (item == null || item.getAmount() <= 0) continue; + ItemSignature sig = VirtualInventory.getSignature(item); + itemsToRemove.merge(sig, (long) item.getAmount(), Long::sum); + } + + return removeItemsAndUpdateSellValue(itemsToRemove); + } + + /** + * Removes already-consolidated items from virtual inventory and updates accumulated sell value. + * THREAD-SAFE: Uses inventoryLock to ensure atomicity. + * @param items Items to remove, keyed by the same signature used by VirtualInventory + * @return true if items were removed successfully + */ + public boolean removeItemsAndUpdateSellValue(Map items) { + if (items == null || items.isEmpty()) { + return true; + } + inventoryLock.lock(); try { // Remove from VirtualInventory (source of truth) - atomic operation within lock @@ -784,13 +830,13 @@ public boolean removeItemsAndUpdateSellValue(List items) { } } - public synchronized void storePreGeneratedLoot(List items, long experience) { + public synchronized void storePreGeneratedLoot(Map items, long experience) { this.preGeneratedItems = items; this.preGeneratedExperience = experience; } - public synchronized List getAndClearPreGeneratedItems() { - List items = preGeneratedItems; + public synchronized Map getAndClearPreGeneratedItems() { + Map items = preGeneratedItems; preGeneratedItems = null; return items; } diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/properties/VirtualInventory.java b/core/src/main/java/github/nighter/smartspawner/spawner/properties/VirtualInventory.java index 27c29719..1108b9e0 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/properties/VirtualInventory.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/properties/VirtualInventory.java @@ -1,6 +1,10 @@ package github.nighter.smartspawner.spawner.properties; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectMaps; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import lombok.Getter; +import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import java.util.*; @@ -8,25 +12,14 @@ public class VirtualInventory { private final Map consolidatedItems; - @Getter - private int maxSlots; - private final Map displayInventoryCache; - private boolean displayCacheDirty; - private int usedSlotsCache; - private long totalItemsCache; - private boolean metricsCacheDirty; + @Getter private int maxSlots; // Cache sorted entries to avoid resorting when display isn't changing private List> sortedEntriesCache; - private org.bukkit.Material preferredSortMaterial; + private Material preferredSortMaterial; public VirtualInventory(int maxSlots) { this.maxSlots = maxSlots; this.consolidatedItems = new ConcurrentHashMap<>(); - this.displayInventoryCache = new HashMap<>(maxSlots); // Pre-size the map - this.displayCacheDirty = true; - this.metricsCacheDirty = true; - this.usedSlotsCache = 0; - this.totalItemsCache = 0; this.sortedEntriesCache = null; this.preferredSortMaterial = null; } @@ -35,155 +28,115 @@ public static ItemSignature getSignature(ItemStack item) { return new ItemSignature(item); } - // Add items in bulk with minimal operations - public void addItems(List items) { - if (items.isEmpty()) return; - - // Pre-allocate space for batch processing - Map itemBatch = new HashMap<>(items.size()); + public void setMaxSlots(int maxSlots) { + this.maxSlots = Math.max(0, maxSlots); + } - // Consolidate all items first - for (ItemStack item : items) { - if (item == null || item.getAmount() <= 0) continue; - ItemSignature sig = getSignature(item); // Use cached signature - itemBatch.merge(sig, (long) item.getAmount(), (a, b) -> a + b); + /* + * FAST PATH + * Used for loading already-consolidated storage data. + */ + public void addItem(ItemStack item, long amount) { + if (item == null || amount <= 0) { + return; } - // Apply all changes in one operation - if (!itemBatch.isEmpty()) { - for (Map.Entry entry : itemBatch.entrySet()) { - consolidatedItems.merge(entry.getKey(), entry.getValue(), (a, b) -> a + b); - } - displayCacheDirty = true; - metricsCacheDirty = true; - sortedEntriesCache = null; - } + ItemSignature signature = getSignature(item); + + consolidatedItems.merge(signature, amount, Long::sum); + + sortedEntriesCache = null; } - // Remove items in bulk with minimal operations - public boolean removeItems(List items) { - if (items.isEmpty()) return true; - - Map toRemove = new HashMap<>(); - - // Calculate total amounts to remove in a single pass - for (ItemStack item : items) { - if (item == null || item.getAmount() <= 0) continue; - // Use cached signature to avoid excessive cloning - ItemSignature sig = getSignature(item); - toRemove.merge(sig, (long) item.getAmount(), (a, b) -> a + b); + + /* + * Bulk insert for already-consolidated storage data. + */ + public void addItems(Map items) { + if (items == null || items.isEmpty()) { + return; } - if (toRemove.isEmpty()) return true; + boolean changed = false; - // Verify we have enough of each item - for (Map.Entry entry : toRemove.entrySet()) { - Long currentAmount = consolidatedItems.getOrDefault(entry.getKey(), 0L); - if (currentAmount < entry.getValue()) { - return false; - } - } + for (Map.Entry entry : items.entrySet()) { + ItemSignature signature = entry.getKey(); + Number amountValue = entry.getValue(); - // Perform removals all at once - boolean updated = false; - for (Map.Entry entry : toRemove.entrySet()) { - ItemSignature sig = entry.getKey(); - long amountToRemove = entry.getValue(); + if (signature == null || amountValue == null) { + continue; + } - consolidatedItems.computeIfPresent(sig, (key, current) -> { - long newAmount = current - amountToRemove; - return newAmount <= 0 ? null : newAmount; - }); + long amount = amountValue.longValue(); + if (amount <= 0) { + continue; + } - updated = true; + consolidatedItems.merge(signature, amount, Long::sum); + changed = true; } - if (updated) { - displayCacheDirty = true; - metricsCacheDirty = true; - sortedEntriesCache = null; // Invalidate sorted entries cache + if (changed) { + sortedEntriesCache = null; } - - return true; } - // Optimized getDisplayInventory method - public Map getDisplayInventory() { - // Return cached result if available - if (!displayCacheDirty) { - // Return a shallow copy to prevent modification of the cache - return Collections.unmodifiableMap(displayInventoryCache); + public boolean removeItems(Map items) { + if (items == null || items.isEmpty()) { + return true; } - // Clear the cache for a fresh rebuild but reuse the existing map - displayInventoryCache.clear(); + Map toRemove = new HashMap<>(items.size()); - if (consolidatedItems.isEmpty()) { - displayCacheDirty = false; - usedSlotsCache = 0; - return Collections.emptyMap(); - } + for (Map.Entry entry : items.entrySet()) { + ItemSignature signature = entry.getKey(); + Number amountValue = entry.getValue(); - // Get and sort the items - only use cached sort result if available - if (sortedEntriesCache == null) { - sortedEntriesCache = new ArrayList<>(consolidatedItems.entrySet()); - // Apply preferred sort if set, otherwise sort alphabetically - if (preferredSortMaterial != null) { - sortedEntriesCache.sort((e1, e2) -> { - // Use getTemplateRef() to avoid cloning - we only need to read the type - boolean e1Preferred = e1.getKey().getMaterial() == preferredSortMaterial; - boolean e2Preferred = e2.getKey().getMaterial() == preferredSortMaterial; + if (signature == null || amountValue == null) { + continue; + } - if (e1Preferred && !e2Preferred) return -1; - if (!e1Preferred && e2Preferred) return 1; - - // Both preferred or both not preferred, sort by material name - return e1.getKey().getMaterialName().compareTo(e2.getKey().getMaterialName()); - }); - } else { - // Use optimized comparator based on cached material name - sortedEntriesCache.sort(Comparator.comparing(e -> e.getKey().getMaterialName())); + long amount = amountValue.longValue(); + if (amount <= 0) { + continue; } - } - // Process items directly to the display inventory - int currentSlot = 0; + toRemove.merge(signature, amount, Long::sum); + } - for (Map.Entry entry : sortedEntriesCache) { - if (currentSlot >= maxSlots) break; + if (toRemove.isEmpty()) { + return true; + } - ItemSignature sig = entry.getKey(); - long totalAmount = entry.getValue(); - int maxStackSize = sig.getMaxStackSize(); + for (Map.Entry entry : toRemove.entrySet()) { + if (consolidatedItems.getOrDefault(entry.getKey(), 0L) < entry.getValue()) { + return false; + } + } - // Create as many stacks as needed for this item type - while (totalAmount > 0 && currentSlot < maxSlots) { - int stackSize = (int) Math.min(totalAmount, maxStackSize); + for (Map.Entry entry : toRemove.entrySet()) { + consolidatedItems.computeIfPresent(entry.getKey(), (key, current) -> { + long remaining = current - entry.getValue(); + return remaining <= 0 ? null : remaining; + }); + } - // Create the display item only once per slot - ItemStack displayItem = sig.getTemplate(); - displayItem.setAmount(stackSize); + sortedEntriesCache = null; - // Store in cache - displayInventoryCache.put(currentSlot, displayItem); + return true; + } - totalAmount -= stackSize; - currentSlot++; - } + public Int2ObjectMap getDisplayPage(int page, int pageSize) { + if (pageSize <= 0) { + return Int2ObjectMaps.emptyMap(); } - // Update cache state - displayCacheDirty = false; - usedSlotsCache = displayInventoryCache.size(); - - // Return unmodifiable map to prevent external changes - return Collections.unmodifiableMap(displayInventoryCache); + int safePage = Math.max(1, page); + int startSlot = (safePage - 1) * pageSize; + return buildDisplaySection(startSlot, pageSize); } - public long getTotalItems() { - if (metricsCacheDirty) { - updateMetricsCache(); - } - return totalItemsCache; + public Int2ObjectMap getDisplayRange(int startSlot, int maxResults) { + return buildDisplaySection(startSlot, maxResults); } public Map getConsolidatedItems() { @@ -191,38 +144,21 @@ public Map getConsolidatedItems() { } public int getUsedSlots() { - // If cache is dirty but we haven't regenerated the display inventory yet, - // calculate a quick estimate instead of rebuilding the whole display - if (displayCacheDirty) { - if (consolidatedItems.isEmpty()) { - return 0; - } + if (consolidatedItems.isEmpty()) { + return 0; + } - // Quick estimate - not perfectly accurate but avoids full rebuilds - int estimatedSlots = 0; - for (Map.Entry entry : consolidatedItems.entrySet()) { - long amount = entry.getValue(); - int maxStackSize = entry.getKey().getMaxStackSize(); - estimatedSlots += (int) Math.ceil((double) amount / maxStackSize); - if (estimatedSlots >= maxSlots) { - return maxSlots; // Cap at max slots - } + // Quick estimate - not perfectly accurate but avoids full rebuilds + int estimatedSlots = 0; + for (Map.Entry entry : consolidatedItems.entrySet()) { + long amount = entry.getValue(); + int maxStackSize = entry.getKey().getMaxStackSize(); + estimatedSlots += (int) Math.ceil((double) amount / maxStackSize); + if (estimatedSlots >= maxSlots) { + return maxSlots; // Cap at max slots } - return estimatedSlots; } - - return usedSlotsCache; - } - - private void updateMetricsCache() { - totalItemsCache = consolidatedItems.values().stream() - .mapToLong(Long::longValue) - .sum(); - metricsCacheDirty = false; - } - - public boolean isDirty() { - return displayCacheDirty; + return estimatedSlots; } /** @@ -240,7 +176,6 @@ public void sortItems(org.bukkit.Material preferredMaterial) { // Only proceed if we have items to sort if (consolidatedItems.isEmpty()) { - this.displayCacheDirty = true; return; } @@ -265,34 +200,91 @@ public void sortItems(org.bukkit.Material preferredMaterial) { .sorted(Comparator.comparing(e -> e.getKey().getMaterialName())) .collect(java.util.stream.Collectors.toList()); } - - // Mark display cache as dirty to force regeneration - this.displayCacheDirty = true; } - /** - * Resizes the virtual inventory to a new maximum slot count. - * If the new size is smaller and items exceed the new capacity, - * items will be truncated based on the current sort order. - * - * @param newMaxSlots The new maximum number of slots - */ - public void resize(int newMaxSlots) { - if (newMaxSlots == this.maxSlots) { - return; // No change needed + private Int2ObjectMap buildDisplaySection(int startSlot, int maxResults) { + if (maxResults <= 0 || startSlot >= maxSlots) { + return Int2ObjectMaps.emptyMap(); + } + + if (consolidatedItems.isEmpty()) { + return Int2ObjectMaps.emptyMap(); + } + + int safeStart = Math.max(0, startSlot); + int sectionLimit = Math.min(maxResults, maxSlots - safeStart); + if (sectionLimit <= 0) { + return Int2ObjectMaps.emptyMap(); } - this.maxSlots = newMaxSlots; + Int2ObjectOpenHashMap section = new Int2ObjectOpenHashMap<>(Math.min(sectionLimit, 45)); + List> sortedEntries = getSortedEntries(); - // Mark caches as dirty since slot count changed - this.displayCacheDirty = true; + int currentGlobalSlot = 0; + int relativeSlot = 0; - // If downsizing, we may need to remove items that exceed capacity - if (newMaxSlots < usedSlotsCache) { - // Let the display inventory rebuild handle the truncation naturally - // Items beyond maxSlots will simply not be displayed - // Note: This doesn't remove items from consolidatedItems, - // but they won't be accessible in the display + for (Map.Entry entry : sortedEntries) { + if (relativeSlot >= sectionLimit || currentGlobalSlot >= maxSlots) { + break; + } + + ItemSignature sig = entry.getKey(); + int maxStackSize = sig.getMaxStackSize(); + if (maxStackSize <= 0) { + continue; + } + + long totalAmount = entry.getValue(); + int stacksForEntry = (int) Math.min( + Integer.MAX_VALUE, + (totalAmount + maxStackSize - 1L) / maxStackSize + ); + + if (currentGlobalSlot + stacksForEntry <= safeStart) { + currentGlobalSlot += stacksForEntry; + continue; + } + + int stacksToSkip = Math.max(0, safeStart - currentGlobalSlot); + long remainingAmount = totalAmount - ((long) stacksToSkip * maxStackSize); + currentGlobalSlot += stacksToSkip; + + ItemStack templateItem = sig.getTemplateRef(); + while (remainingAmount > 0 && relativeSlot < sectionLimit && currentGlobalSlot < maxSlots) { + ItemStack displayItem = templateItem.clone(); + displayItem.setAmount((int) Math.min(remainingAmount, maxStackSize)); + section.put(relativeSlot++, displayItem); + + remainingAmount -= maxStackSize; + currentGlobalSlot++; + } } + + return Int2ObjectMaps.unmodifiable(section); + } + + private List> getSortedEntries() { + if (sortedEntriesCache == null) { + sortedEntriesCache = new ArrayList<>(consolidatedItems.entrySet()); + sortEntries(sortedEntriesCache); + } + return sortedEntriesCache; + } + + private void sortEntries(List> entries) { + if (preferredSortMaterial != null) { + entries.sort((e1, e2) -> { + boolean e1Preferred = e1.getKey().getMaterial() == preferredSortMaterial; + boolean e2Preferred = e2.getKey().getMaterial() == preferredSortMaterial; + + if (e1Preferred && !e2Preferred) return -1; + if (!e1Preferred && e2Preferred) return 1; + + return e1.getKey().getMaterialName().compareTo(e2.getKey().getMaterialName()); + }); + return; + } + + entries.sort(Comparator.comparing(e -> e.getKey().getMaterialName())); } } diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/sell/SellResult.java b/core/src/main/java/github/nighter/smartspawner/spawner/sell/SellResult.java index 3f0310e9..5714f631 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/sell/SellResult.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/sell/SellResult.java @@ -1,11 +1,11 @@ package github.nighter.smartspawner.spawner.sell; +import github.nighter.smartspawner.spawner.properties.ItemSignature; import lombok.Getter; -import org.bukkit.inventory.ItemStack; -import java.util.ArrayList; import java.util.Collections; -import java.util.List; +import java.util.HashMap; +import java.util.Map; public class SellResult { @Getter @@ -13,25 +13,25 @@ public class SellResult { @Getter private final long itemsSold; @Getter - private final List itemsToRemove; + private final Map itemsToRemove; @Getter private final long timestamp; @Getter private final boolean successful; - public SellResult(double totalValue, long itemsSold, List itemsToRemove) { + public SellResult(double totalValue, long itemsSold, Map itemsToRemove) { this.totalValue = totalValue; this.itemsSold = itemsSold; - this.itemsToRemove = new ArrayList<>(itemsToRemove); + this.itemsToRemove = new HashMap<>(itemsToRemove); this.timestamp = System.currentTimeMillis(); this.successful = totalValue > 0.0 && !itemsToRemove.isEmpty(); } public static SellResult empty() { - return new SellResult(0.0, 0, Collections.emptyList()); + return new SellResult(0.0, 0, Collections.emptyMap()); } public boolean hasItems() { return !itemsToRemove.isEmpty(); } -} \ No newline at end of file +} diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/sell/SpawnerSellManager.java b/core/src/main/java/github/nighter/smartspawner/spawner/sell/SpawnerSellManager.java index 66aadab2..753693e5 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/sell/SpawnerSellManager.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/sell/SpawnerSellManager.java @@ -140,7 +140,11 @@ private void applySellResult(Player player, SpawnerData spawner, SellResult sell // Fire the cancellable API event if (SpawnerSellEvent.getHandlerList().getRegisteredListeners().length != 0) { SpawnerSellEvent event = new SpawnerSellEvent( - player, spawner.getSpawnerLocation(), sellResult.getItemsToRemove(), amount, spawner.getEntityType()); + player, + spawner.getSpawnerLocation(), + toApiItemStacks(sellResult.getItemsToRemove()), + amount, + spawner.getEntityType()); Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) return; if (event.getMoneyAmount() >= 0) amount = event.getMoneyAmount(); @@ -179,33 +183,35 @@ private void applySellResult(Player player, SpawnerData spawner, SellResult sell } /** - * Calculates the total sell value and constructs the list of {@link ItemStack}s to remove. + * Calculates the total sell value and records the consolidated item signatures to remove. * Pure computation – no Bukkit API calls, safe to run on an async thread. */ - private SellResult calculateSellValue(Map consolidatedItems, - double totalValue) { + private SellResult calculateSellValue(Map consolidatedItems, double totalValue) { long totalItemsSold = 0; - ArrayList itemsToRemove = new ArrayList<>(); for (Map.Entry entry : consolidatedItems.entrySet()) { - ItemSignature signature = entry.getKey(); - long amount = entry.getValue(); - int maxStackSize = signature.getMaxStackSize(); + totalItemsSold += entry.getValue(); + } + + return new SellResult(totalValue, totalItemsSold, consolidatedItems); + } - totalItemsSold += amount; + private List toApiItemStacks(Map items) { + if (items == null || items.isEmpty()) { + return Collections.emptyList(); + } - int stacksNeeded = (int) Math.ceil((double) amount / maxStackSize); - itemsToRemove.ensureCapacity(itemsToRemove.size() + stacksNeeded); + List apiItems = new ArrayList<>(items.size()); - long remaining = amount; - while (remaining > 0) { - ItemStack stack = signature.getTemplate(); - stack.setAmount((int) Math.min(remaining, maxStackSize)); - itemsToRemove.add(stack); - remaining -= stack.getAmount(); - } + for (Map.Entry entry : items.entrySet()) { + ItemSignature signature = entry.getKey(); + Long amount = entry.getValue(); + + ItemStack stack = signature.getTemplate(); + stack.setAmount((int) Math.min(amount, Integer.MAX_VALUE)); + apiItems.add(stack); } - return new SellResult(totalValue, totalItemsSold, itemsToRemove); + return apiItems; } } diff --git a/core/src/main/java/github/nighter/smartspawner/language/LRUCache.java b/core/src/main/java/github/nighter/smartspawner/utils/LRUCache.java similarity index 72% rename from core/src/main/java/github/nighter/smartspawner/language/LRUCache.java rename to core/src/main/java/github/nighter/smartspawner/utils/LRUCache.java index 6df30b2e..fe96fea9 100644 --- a/core/src/main/java/github/nighter/smartspawner/language/LRUCache.java +++ b/core/src/main/java/github/nighter/smartspawner/utils/LRUCache.java @@ -1,7 +1,10 @@ -package github.nighter.smartspawner.language; +package github.nighter.smartspawner.utils; + +import com.google.common.base.Preconditions; import java.util.LinkedHashMap; import java.util.Map; +import java.util.function.Function; /** * A simple LRU (Least Recently Used) cache implementation @@ -52,6 +55,25 @@ public synchronized V put(K key, V value) { return cache.put(key, value); } + /** + * Returns the value associated with the specified key, computing and + * caching it with the supplied mapping function when no mapping exists. + * + *

Accessing an existing entry updates its recency, and adding a new + * entry may evict the least recently used entry if the cache exceeds its + * configured capacity.

+ * + * @param key The key whose associated value is to be returned or computed + * @param mappingFunction The function used to create a value when the key is absent + * @return The existing or newly computed value associated with the key + * @throws NullPointerException if {@code key} is null + */ + public synchronized V get(K key, Function mappingFunction) { + Preconditions.checkNotNull(key); + + return cache.computeIfAbsent(key, mappingFunction); + } + /** * Removes all entries from the cache */ @@ -86,4 +108,4 @@ public synchronized void resize(int newCapacity) { this.capacity = newCapacity; // The LinkedHashMap will automatically adjust its size on the next put operation } -} \ No newline at end of file +}