diff --git a/build.gradle.kts b/build.gradle.kts index e9664cca..f0d3dd6b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ allprojects { apply(plugin = "maven-publish") group = "github.nighter" - version = "1.6.6" + version = "1.6.6-DEV" repositories { mavenCentral() @@ -98,4 +98,3 @@ tasks.withType().configureEach { options.release.set(targetJavaVersion) } } - diff --git a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java index 6a5f04ae..32e66588 100644 --- a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java +++ b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java @@ -13,6 +13,7 @@ import github.nighter.smartspawner.commands.list.gui.adminstacker.AdminStackerHandler; import github.nighter.smartspawner.commands.list.gui.serverselection.ServerSelectionHandler; import github.nighter.smartspawner.commands.prices.PricesGUI; +import github.nighter.smartspawner.config.Config; import github.nighter.smartspawner.extras.HopperConfig; import github.nighter.smartspawner.spawner.config.SpawnerSettingsConfig; import github.nighter.smartspawner.spawner.config.ItemSpawnerSettingsConfig; @@ -167,6 +168,7 @@ public class SmartSpawner extends JavaPlugin implements SmartSpawnerPlugin { public void onEnable() { long startTime = System.currentTimeMillis(); instance = this; + Config.load(this); // Initialize version-specific components initializeVersionComponents(); diff --git a/core/src/main/java/github/nighter/smartspawner/commands/reload/ReloadSubCommand.java b/core/src/main/java/github/nighter/smartspawner/commands/reload/ReloadSubCommand.java index 82c0ad9e..9d58bfc1 100644 --- a/core/src/main/java/github/nighter/smartspawner/commands/reload/ReloadSubCommand.java +++ b/core/src/main/java/github/nighter/smartspawner/commands/reload/ReloadSubCommand.java @@ -3,6 +3,7 @@ import com.mojang.brigadier.context.CommandContext; import github.nighter.smartspawner.SmartSpawner; import github.nighter.smartspawner.commands.BaseSubCommand; +import github.nighter.smartspawner.config.Config; import io.papermc.paper.command.brigadier.CommandSourceStack; import org.bukkit.command.CommandSender; import org.jspecify.annotations.NullMarked; @@ -54,6 +55,7 @@ private void reloadAll(CommandSender sender) { // Reload all configurations plugin.reloadConfig(); + Config.reload(plugin); // Reload components in dependency order plugin.setUpHopperHandler(); diff --git a/core/src/main/java/github/nighter/smartspawner/config/Config.java b/core/src/main/java/github/nighter/smartspawner/config/Config.java new file mode 100644 index 00000000..27ad8616 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/config/Config.java @@ -0,0 +1,30 @@ +package github.nighter.smartspawner.config; + +import github.nighter.smartspawner.SmartSpawner; +import lombok.AccessLevel; +import lombok.Getter; +import org.bukkit.configuration.file.FileConfiguration; + +@Getter +public class Config { + @Getter(AccessLevel.NONE) + private static volatile Config instance; + + private final boolean optimizedLootgen; + + private Config(FileConfiguration config) { + this.optimizedLootgen = config.getBoolean("loot_generation.optimized_generation"); + } + + public static Config get() { + return instance; + } + + public static void reload(SmartSpawner plugin) { + load(plugin); + } + + public static void load(SmartSpawner plugin) { + instance = new Config(plugin.getConfig()); + } +} 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 abf7adcc..a04572b8 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 @@ -2,6 +2,7 @@ import github.nighter.smartspawner.SmartSpawner; import github.nighter.smartspawner.spawner.data.storage.SpawnerStorage; +import github.nighter.smartspawner.spawner.properties.ItemSignature; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.spawner.properties.VirtualInventory; import github.nighter.smartspawner.Scheduler; @@ -229,7 +230,7 @@ private boolean saveSpawnerBatch(Map spawners) { VirtualInventory virtualInv = spawner.getVirtualInventory(); if (virtualInv != null) { - Map items = virtualInv.getConsolidatedItems(); + Map items = virtualInv.getConsolidatedItems(); List serializedItems = ItemStackSerializer.serializeInventory(items); spawnerData.set(path + ".inventory", serializedItems); } @@ -436,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 d99a0491..5a11ed9b 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 @@ -5,6 +5,7 @@ import github.nighter.smartspawner.commands.list.gui.CrossServerSpawnerData; import github.nighter.smartspawner.spawner.data.storage.SpawnerStorage; import github.nighter.smartspawner.spawner.data.storage.StorageMode; +import github.nighter.smartspawner.spawner.properties.ItemSignature; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.spawner.properties.VirtualInventory; import github.nighter.smartspawner.spawner.utils.ItemStackSerializer; @@ -595,7 +596,7 @@ private String serializeInventory(VirtualInventory virtualInv) { return null; } - Map items = virtualInv.getConsolidatedItems(); + Map items = virtualInv.getConsolidatedItems(); if (items.isEmpty()) { return null; } @@ -679,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/main/SpawnerMenuUI.java b/core/src/main/java/github/nighter/smartspawner/spawner/gui/main/SpawnerMenuUI.java index e9469a11..a70a2b66 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/gui/main/SpawnerMenuUI.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/gui/main/SpawnerMenuUI.java @@ -1,5 +1,6 @@ package github.nighter.smartspawner.spawner.gui.main; +import github.nighter.smartspawner.spawner.properties.ItemSignature; import net.kyori.adventure.text.Component; import github.nighter.smartspawner.SmartSpawner; import github.nighter.smartspawner.nms.VersionInitializer; @@ -255,7 +256,7 @@ public ItemStack createLootStorageItem(SpawnerData spawner) { } List lootComponents = Collections.emptyList(); if (usedPlaceholders.contains("loot_items")) { - Map storedItems = virtualInventory.getConsolidatedItems(); + Map storedItems = virtualInventory.getConsolidatedItems(); lootComponents = buildLootItemComponents(spawner.getEntityType(), storedItems); } @@ -282,11 +283,11 @@ public ItemStack createLootStorageItem(SpawnerData spawner) { return chestItem; } - private String buildLootItemsText(EntityType entityType, Map storedItems) { + private String buildLootItemsText(EntityType entityType, Map storedItems) { // Create material-to-amount map for quick lookups Map materialAmountMap = new HashMap<>(); - for (Map.Entry entry : storedItems.entrySet()) { - Material material = entry.getKey().getTemplateRef().getType(); + for (Map.Entry entry : storedItems.entrySet()) { + Material material = entry.getKey().getMaterial(); materialAmountMap.merge(material, entry.getValue(), Long::sum); } @@ -327,13 +328,12 @@ private String buildLootItemsText(EntityType entityType, Map> sortedItems = + List> sortedItems = new ArrayList<>(storedItems.entrySet()); sortedItems.sort(Comparator.comparing(e -> e.getKey().getMaterialName())); - for (Map.Entry entry : sortedItems) { - ItemStack templateItem = entry.getKey().getTemplateRef(); - Material material = templateItem.getType(); + for (Map.Entry entry : sortedItems) { + Material material = entry.getKey().getMaterial(); long amount = entry.getValue(); String materialName = languageManager.getVanillaItemName(material); @@ -602,10 +602,10 @@ private int calculatePercentage(long current, long maximum) { return maximum > 0 ? (int) ((double) current / maximum * 100) : 0; } - private List buildLootItemComponents(EntityType entityType, Map storedItems) { + private List buildLootItemComponents(EntityType entityType, Map storedItems) { Map materialAmountMap = new HashMap<>(); - for (Map.Entry entry : storedItems.entrySet()) { - Material material = entry.getKey().getTemplateRef().getType(); + for (Map.Entry entry : storedItems.entrySet()) { + Material material = entry.getKey().getMaterial(); materialAmountMap.merge(material, entry.getValue(), Long::sum); } @@ -628,11 +628,11 @@ private List buildLootItemComponents(EntityType entityType, Map> sortedItems = + List> sortedItems = new ArrayList<>(storedItems.entrySet()); sortedItems.sort(Comparator.comparing(e -> e.getKey().getMaterialName())); - for (Map.Entry entry : sortedItems) { - Material material = entry.getKey().getTemplateRef().getType(); + for (Map.Entry entry : sortedItems) { + Material material = entry.getKey().getMaterial(); long amount = entry.getValue(); String formattedAmount = languageManager.formatNumber(amount); components.add(languageManager.buildTranslatableGuiLootLine( diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/gui/sell/SpawnerSellConfirmUI.java b/core/src/main/java/github/nighter/smartspawner/spawner/gui/sell/SpawnerSellConfirmUI.java index b692fa71..6e105110 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/gui/sell/SpawnerSellConfirmUI.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/gui/sell/SpawnerSellConfirmUI.java @@ -1,5 +1,6 @@ package github.nighter.smartspawner.spawner.gui.sell; +import github.nighter.smartspawner.spawner.properties.ItemSignature; import net.kyori.adventure.text.Component; import github.nighter.smartspawner.SmartSpawner; import github.nighter.smartspawner.language.LanguageManager; @@ -164,7 +165,7 @@ private ItemStack createConfirmButton(Material material, Map pla private ItemStack createSpawnerInfoButton(Player player, SpawnerData spawner, Map placeholders) { // Build loot item components for {loot_items} placeholder - Map storedItems = spawner.getVirtualInventory().getConsolidatedItems(); + Map storedItems = spawner.getVirtualInventory().getConsolidatedItems(); List lootComponents = buildSellInfoLootComponents(spawner, storedItems); // Prepare the meta modifier consumer @@ -200,10 +201,10 @@ private ItemStack createSpawnerInfoButton(Player player, SpawnerData spawner, Ma return spawnerItem; } - private List buildSellInfoLootComponents(SpawnerData spawner, Map storedItems) { + private List buildSellInfoLootComponents(SpawnerData spawner, Map storedItems) { Map materialAmountMap = new HashMap<>(); - for (Map.Entry entry : storedItems.entrySet()) { - Material material = entry.getKey().getTemplateRef().getType(); + for (Map.Entry entry : storedItems.entrySet()) { + Material material = entry.getKey().getMaterial(); materialAmountMap.merge(material, entry.getValue(), Long::sum); } @@ -227,10 +228,10 @@ private List buildSellInfoLootComponents(SpawnerData spawner, Map> sortedItems = new ArrayList<>(storedItems.entrySet()); + List> sortedItems = new ArrayList<>(storedItems.entrySet()); sortedItems.sort(Comparator.comparing(e -> e.getKey().getMaterialName())); - for (Map.Entry entry : sortedItems) { - Material material = entry.getKey().getTemplateRef().getType(); + for (Map.Entry entry : sortedItems) { + Material material = entry.getKey().getMaterial(); long amount = entry.getValue(); String formattedAmount = languageManager.formatNumber(amount); components.add(languageManager.buildTranslatableGuiLootLine( 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 a1fe17ad..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,15 +7,18 @@ 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.entity.EntityType; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; @@ -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,50 +468,8 @@ 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(); + Map storedItems = spawner.getVirtualInventory().getConsolidatedItems(); List lootComponents = buildStorageInfoLootComponents(spawner, storedItems); Map placeholders = new HashMap<>(); @@ -588,10 +521,10 @@ private ItemStack createStorageSpawnerInfoButton(SpawnerData spawner, Material m } private List buildStorageInfoLootComponents(SpawnerData spawner, - Map storedItems) { + Map storedItems) { Map materialAmountMap = new HashMap<>(); - for (Map.Entry entry : storedItems.entrySet()) { - Material mat = entry.getKey().getTemplateRef().getType(); + for (Map.Entry entry : storedItems.entrySet()) { + Material mat = entry.getKey().getMaterial(); materialAmountMap.merge(mat, entry.getValue(), Long::sum); } @@ -619,10 +552,10 @@ private List buildStorageInfoLootComponents(SpawnerData spawner, "storage_spawner_info_button.loot_items", mat, formattedAmount, chance)); } } else { - List> sortedItems = new ArrayList<>(storedItems.entrySet()); + List> sortedItems = new ArrayList<>(storedItems.entrySet()); sortedItems.sort(Comparator.comparing(e -> e.getKey().getMaterialName())); - for (Map.Entry entry : sortedItems) { - Material mat = entry.getKey().getTemplateRef().getType(); + for (Map.Entry entry : sortedItems) { + Material mat = entry.getKey().getMaterial(); long amount = entry.getValue(); String formattedAmount = languageManager.formatNumber(amount); components.add(languageManager.buildTranslatableGuiLootLine( @@ -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 7f6e13cf..e439ec46 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 @@ -1,7 +1,9 @@ package github.nighter.smartspawner.spawner.lootgen; import github.nighter.smartspawner.SmartSpawner; +import github.nighter.smartspawner.config.Config; import github.nighter.smartspawner.spawner.gui.synchronization.SpawnerGuiViewManager; +import github.nighter.smartspawner.spawner.properties.ItemSignature; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.spawner.data.SpawnerManager; import github.nighter.smartspawner.spawner.properties.VirtualInventory; @@ -12,19 +14,17 @@ import org.bukkit.inventory.ItemStack; import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.ThreadLocalRandom; public class SpawnerLootGenerator { private final SmartSpawner plugin; private final SpawnerGuiViewManager spawnerGuiViewManager; private final SpawnerManager spawnerManager; - private final Random random; public SpawnerLootGenerator(SmartSpawner plugin) { this.plugin = plugin; this.spawnerGuiViewManager = plugin.getSpawnerGuiViewManager(); this.spawnerManager = plugin.getSpawnerManager(); - this.random = new Random(); } public void spawnLootToSpawner(SpawnerData spawner) { @@ -60,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); } @@ -126,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; } } @@ -183,164 +174,205 @@ public void spawnLootToSpawner(SpawnerData spawner) { } public LootResult generateLoot(int minMobs, int maxMobs, SpawnerData spawner) { + ThreadLocalRandom random = ThreadLocalRandom.current(); - int mobCount = random.nextInt(maxMobs - minMobs + 1) + minMobs; - long totalExperienceLong = (long) spawner.getEntityExperienceValue() * mobCount; - long totalExperience = Math.min(totalExperienceLong, Long.MAX_VALUE); + int mobCount = generateMobCount(minMobs, maxMobs, random); + 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<>(validItems.size()); + + boolean optimizedLootgen = Config.get().isOptimizedLootgen(); - // Process mobs in batch rather than individually for (LootItem lootItem : validItems) { - // Calculate the probability for the entire mob batch at once - int successfulDrops = 0; + int totalAmount; - // Calculate binomial distribution - how many mobs will drop this item - for (int i = 0; i < mobCount; i++) { - if (random.nextDouble() * 100 <= lootItem.chance()) { - successfulDrops++; - } + if (optimizedLootgen && shouldApproximate(lootItem.chance(), mobCount)) { + totalAmount = generateApproximatedLoot(lootItem, mobCount, random); + } else { + totalAmount = generateExactLoot(lootItem, mobCount, random); } - if (successfulDrops > 0) { - // Create item just once per loot type - ItemStack prototype = lootItem.createItemStack(random); - if (prototype != null) { - // Total amount across all mobs - int totalAmount = 0; - for (int i = 0; i < successfulDrops; i++) { - totalAmount += lootItem.generateAmount(random); - } + if (totalAmount <= 0) { + continue; + } - if (totalAmount > 0) { - // Add to consolidated map - consolidatedLoot.merge(prototype, totalAmount, (a, b) -> a + b); - } - } + ItemStack prototype = lootItem.createItemStack(random); + if (prototype == null || prototype.getType() == Material.AIR) { + continue; } + + ItemSignature signature = VirtualInventory.getSignature(prototype); + consolidatedLoot.merge(signature, totalAmount, Integer::sum); + } + + return new LootResult(consolidatedLoot, totalExperience); + } + + private int generateMobCount(int minMobs, int maxMobs, ThreadLocalRandom random) { + int lowerBound = Math.max(0, Math.min(minMobs, maxMobs)); + int upperBound = Math.max(0, Math.max(minMobs, maxMobs)); + + if (upperBound == lowerBound) { + return upperBound; } - // 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(); + return random.nextInt(lowerBound, upperBound + 1); + } + + private int generateExactLoot(LootItem lootItem, int mobCount, ThreadLocalRandom random) { + int successfulDrops = 0; + + double p = lootItem.chance() / 100.0; + + for (int i = 0; i < mobCount; i++) { + if (random.nextDouble() < p) { + successfulDrops++; } } - return new LootResult(finalLoot, totalExperience); + if (successfulDrops == 0) { + return 0; + } + + int totalAmount = 0; + + for (int i = 0; i < successfulDrops; i++) { + totalAmount += lootItem.generateAmount(random); + } + + return totalAmount; + } + + private int generateApproximatedLoot(LootItem lootItem, int mobCount, ThreadLocalRandom random) { + double p = lootItem.chance() / 100.0; + double expectedDrops = mobCount * p; + double avgAmount = lootItem.getAverageAmount(); + + double jitter = p != 1.0 + ? 0.95 + random.nextDouble() * 0.10 + : 1.0; + + 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 - VirtualInventory.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; - // We've filled all slots, stop processing - break; + 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); + + 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().getTemplateRef().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 - VirtualInventory.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; + } + + // Determines whether to use expected-value approximation + private boolean shouldApproximate(double chance, int mobCount) { + // simple heuristic: use expected if at least one item can be generated + if (chance <= 0D) return false; + return mobCount > 97.5D / chance; } /** @@ -373,11 +405,11 @@ private void handleGuiUpdates(SpawnerData spawner) { spawner.updateHologramData(); } } - + /** * Pre-generates loot asynchronously for improved UX. * Loot is calculated in background before timer expires, then added instantly when ready. - * + * *

This method: *

    *
  • Checks spawner capacity before generation
  • @@ -385,40 +417,37 @@ private void handleGuiUpdates(SpawnerData spawner) { *
  • Invokes callback with generated items and experience
  • *
  • Handles thread-safety with proper locking
  • *
- * + * * @param spawner The spawner to pre-generate loot for * @param callback Callback invoked with generated loot (items, experience) */ 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; } @@ -429,15 +458,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() ); }); @@ -446,16 +470,9 @@ public void preGenerateLoot(SpawnerData spawner, LootGenerationCallback callback } } - private LootResult generateExperienceOnlyLoot(int minMobs, int maxMobs, SpawnerData spawner) { - int mobCount = random.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. - * + * *

This method: *

    *
  • Validates pre-generated loot is not empty
  • @@ -464,14 +481,14 @@ private LootResult generateExperienceOnlyLoot(int minMobs, int maxMobs, SpawnerD *
  • Updates lastSpawnTime to maintain cycle timing
  • *
  • Triggers GUI updates and marks spawner for persistence
  • *
- * + * *

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()); } @@ -480,11 +497,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; } @@ -523,7 +540,7 @@ public void addPreGeneratedLoot(SpawnerData spawner, List items, long Scheduler.runTaskAsync(() -> { boolean changed = false; - + if (experience > 0 && spawner.getSpawnerExp() < spawner.getMaxStoredExp()) { long currentExp = spawner.getSpawnerExp(); long maxExp = spawner.getMaxStoredExp(); @@ -537,29 +554,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; } } } @@ -585,7 +592,7 @@ public void addPreGeneratedLoot(SpawnerData spawner, List items, long } }); } - + /** * Callback interface for asynchronous loot pre-generation. * Invoked when loot generation completes with the generated items and experience. @@ -594,10 +601,10 @@ public void addPreGeneratedLoot(SpawnerData spawner, List items, long 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); } -} \ No newline at end of file +} 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/lootgen/loot/LootItem.java b/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/loot/LootItem.java index f6e0fc17..95004ef0 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/loot/LootItem.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/lootgen/loot/LootItem.java @@ -45,7 +45,11 @@ public int generateAmount(Random random) { return random.nextInt(maxAmount - minAmount + 1) + minAmount; } + public double getAverageAmount() { + return (this.maxAmount + this.minAmount) / 2.0; + } + public boolean isAvailable() { return material != null; } -} \ No newline at end of file +} 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 new file mode 100644 index 00000000..a5348f73 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/properties/ItemSignature.java @@ -0,0 +1,99 @@ +package github.nighter.smartspawner.spawner.properties; + +import lombok.Getter; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; + +public class ItemSignature { + private final ItemStack template; + private final int hashCode; + // Cache purposes + @Getter private final Material material; + @Getter private final int maxStackSize; + @Getter private final int damage; + + public ItemSignature(ItemStack item) { + this.template = item.clone(); + this.template.setAmount(1); + this.material = template.getType(); + this.maxStackSize = item.getMaxStackSize(); + this.damage = getItemDamage(template); + this.hashCode = calculateHashCode(); + } + + // Replace the current calculateHashCode() method with: + private int calculateHashCode() { + // Use a faster hash algorithm and cache more item properties + int result = 31 * this.material.ordinal(); // Using ordinal() instead of name() hashing + result = 31 * result + this.damage; + + // Only access ItemMeta when needed + if (template.hasItemMeta()) { + ItemMeta meta = template.getItemMeta(); + // Extract only the essential meta properties that determine similarity + result = 31 * result + (meta.hasDisplayName() ? meta.displayName().hashCode() : 0); + result = 31 * result + (meta.hasLore() ? meta.lore().hashCode() : 0); + result = 31 * result + (meta.hasEnchants() ? meta.getEnchants().hashCode() : 0); + } + return result; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ItemSignature that)) return false; + + // First compare cheap properties + if (material != that.material || this.damage != that.damage) { + return false; + } + + // Only check ItemMeta if types match + boolean thisHasMeta = template.hasItemMeta(); + boolean thatHasMeta = that.template.hasItemMeta(); + + if (thisHasMeta != thatHasMeta) { + return false; + } + + // If both have no meta, they're similar enough + if (!thisHasMeta) { + return true; + } + + // For complex items, fall back to isSimilar but only as a last resort + return template.isSimilar(that.template); + } + + @Override + public int hashCode() { + return hashCode; + } + + public ItemStack getTemplate() { + return template.clone(); + } + + // Non-cloning method for internal use + public ItemStack getTemplateRef() { + return template; + } + + public String getMaterialName() { + return material.name(); + } + + private int getItemDamage(ItemStack item) { + if (!item.hasItemMeta()) { + return 0; + } + ItemMeta meta = item.getItemMeta(); + if (meta instanceof Damageable damageable) { + return damageable.getDamage(); + } + return 0; + } + +} 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 4800a1af..519fd889 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; if (baseSpawnerDelay <= 0) { @@ -321,9 +332,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(); @@ -337,11 +345,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.min(Math.max(0L, exp), maxStoredExp); updateHologramData(); @@ -373,6 +376,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; @@ -455,7 +459,7 @@ public List getValidLootItems() { } private boolean isLootItemValid(LootItem item) { - ItemStack example = item.createItemStack(new Random()); + ItemStack example = item.createItemStack(ThreadLocalRandom.current()); return example != null && !filteredItems.contains(example.getType()); } @@ -550,29 +554,38 @@ 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()) { + if (entry.getKey() == null || entry.getValue() == null) { + continue; + } + // Use getTemplateRef() to avoid cloning - we only need to read properties ItemStack template = entry.getKey().getTemplateRef(); - long amount = entry.getValue(); + long amount = entry.getValue().longValue(); double itemPrice = findItemPrice(template, priceCache); if (itemPrice > 0.0) { addedValue += itemPrice * amount; } } - this.accumulatedSellValue += addedValue; + if (addedValue > 0.0) { + this.accumulatedSellValue.addAndGet(addedValue); + } this.sellValueDirty = false; } @@ -586,27 +599,42 @@ public void decrementSellValue(List itemsRemoved, Map return; } - // Consolidate removed items - Map consolidated = new java.util.HashMap<>(); + Map consolidated = new java.util.HashMap<>(); for (ItemStack item : itemsRemoved) { if (item == null || item.getAmount() <= 0) continue; - // Use cached signature to avoid excessive cloning - VirtualInventory.ItemSignature sig = VirtualInventory.getSignature(item); - consolidated.merge(sig, (long) item.getAmount(), (a, b) -> a + b); + ItemSignature sig = VirtualInventory.getSignature(item); + 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; + } + // Use getTemplateRef() to avoid cloning - we only need to read properties ItemStack template = entry.getKey().getTemplateRef(); - long amount = entry.getValue(); + long amount = entry.getValue().longValue(); double itemPrice = findItemPrice(template, priceCache); if (itemPrice > 0.0) { removedValue += itemPrice * amount; } } - this.accumulatedSellValue = Math.max(0.0, this.accumulatedSellValue - removedValue); + subtractAccumulatedSellValue(removedValue); } /** @@ -615,7 +643,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; } @@ -624,10 +652,10 @@ public void recalculateSellValue() { Map priceCache = createPriceCache(); // Calculate from current inventory - Map items = virtualInventory.getConsolidatedItems(); + Map items = virtualInventory.getConsolidatedItems(); double totalValue = 0.0; - for (Map.Entry entry : items.entrySet()) { + for (Map.Entry entry : items.entrySet()) { // Use getTemplateRef() to avoid cloning - we only need to read properties ItemStack template = entry.getKey().getTemplateRef(); long amount = entry.getValue(); @@ -637,10 +665,23 @@ public void recalculateSellValue() { } } - 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 @@ -721,35 +762,22 @@ private String createItemKey(ItemStack item) { } /** - * 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; } - // 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 - VirtualInventory.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(); @@ -767,13 +795,31 @@ 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 boolean removed = virtualInventory.removeItems(items); - // Update sell value atomically if removal was successful if (removed && !sellValueDirty) { Map priceCache = createPriceCache(); decrementSellValue(items, priceCache); @@ -785,13 +831,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 4a24be5b..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,297 +1,142 @@ 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 org.bukkit.inventory.meta.Damageable; -import org.bukkit.inventory.meta.ItemMeta; import java.util.*; import java.util.concurrent.ConcurrentHashMap; 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; - - // Add an LRU cache for expensive item operations - private static final int ITEM_CACHE_SIZE = 128; - private static final Map signatureCache = - Collections.synchronizedMap(new LinkedHashMap(ITEM_CACHE_SIZE, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > ITEM_CACHE_SIZE; - } - }); + 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; } - public static class ItemSignature { - private final ItemStack template; - private final int hashCode; - @Getter - private final String materialName; - - public ItemSignature(ItemStack item) { - this.template = item.clone(); - this.template.setAmount(1); - this.materialName = item.getType().name(); - this.hashCode = calculateHashCode(); - } - - // Replace the current calculateHashCode() method with: - private int calculateHashCode() { - // Use a faster hash algorithm and cache more item properties - int result = 31 * template.getType().ordinal(); // Using ordinal() instead of name() hashing - result = 31 * result + getItemDamage(template); - - // Only access ItemMeta when needed - if (template.hasItemMeta()) { - ItemMeta meta = template.getItemMeta(); - // Extract only the essential meta properties that determine similarity - result = 31 * result + (meta.hasDisplayName() ? meta.displayName().hashCode() : 0); - result = 31 * result + (meta.hasLore() ? meta.lore().hashCode() : 0); - result = 31 * result + (meta.hasEnchants() ? meta.getEnchants().hashCode() : 0); - } - return result; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof ItemSignature)) return false; - ItemSignature that = (ItemSignature) o; + public static ItemSignature getSignature(ItemStack item) { + return new ItemSignature(item); + } - // First compare cheap properties - if (template.getType() != that.template.getType() || - getItemDamage(template) != getItemDamage(that.template)) { - return false; - } + public void setMaxSlots(int maxSlots) { + this.maxSlots = Math.max(0, maxSlots); + } - // Only check ItemMeta if types match - boolean thisHasMeta = template.hasItemMeta(); - boolean thatHasMeta = that.template.hasItemMeta(); + /* + * FAST PATH + * Used for loading already-consolidated storage data. + */ + public void addItem(ItemStack item, long amount) { + if (item == null || amount <= 0) { + return; + } - if (thisHasMeta != thatHasMeta) { - return false; - } + ItemSignature signature = getSignature(item); - // If both have no meta, they're similar enough - if (!thisHasMeta) { - return true; - } + consolidatedItems.merge(signature, amount, Long::sum); - // For complex items, fall back to isSimilar but only as a last resort - return template.isSimilar(that.template); - } + sortedEntriesCache = null; + } - @Override - public int hashCode() { - return hashCode; + /* + * Bulk insert for already-consolidated storage data. + */ + public void addItems(Map items) { + if (items == null || items.isEmpty()) { + return; } - public ItemStack getTemplate() { - return template.clone(); - } + boolean changed = false; - // Non-cloning method for internal use - public ItemStack getTemplateRef() { - return template; - } + for (Map.Entry entry : items.entrySet()) { + ItemSignature signature = entry.getKey(); + Number amountValue = entry.getValue(); - private int getItemDamage(ItemStack item) { - if (!item.hasItemMeta()) { - return 0; + if (signature == null || amountValue == null) { + continue; } - ItemMeta meta = item.getItemMeta(); - if (meta instanceof Damageable) { - return ((Damageable) meta).getDamage(); + + long amount = amountValue.longValue(); + if (amount <= 0) { + continue; } - return 0; + + consolidatedItems.merge(signature, amount, Long::sum); + changed = true; } + if (changed) { + sortedEntriesCache = null; + } } - public static ItemSignature getSignature(ItemStack item) { - // First try to get from cache - ItemSignature cachedSig = signatureCache.get(item); - if (cachedSig != null) { - return cachedSig; + public boolean removeItems(Map items) { + if (items == null || items.isEmpty()) { + return true; } - // Create new signature and cache it - ItemSignature newSig = new ItemSignature(item); - signatureCache.put(item.clone(), newSig); - return newSig; - } + Map toRemove = new HashMap<>(items.size()); - // Add items in bulk with minimal operations - public void addItems(List items) { - if (items.isEmpty()) return; + for (Map.Entry entry : items.entrySet()) { + ItemSignature signature = entry.getKey(); + Number amountValue = entry.getValue(); - // Pre-allocate space for batch processing - Map itemBatch = new HashMap<>(items.size()); - - // 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); - } + if (signature == null || amountValue == null) { + continue; + } - // 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); + long amount = amountValue.longValue(); + if (amount <= 0) { + continue; } - displayCacheDirty = true; - metricsCacheDirty = true; - 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); + + toRemove.merge(signature, amount, Long::sum); } - if (toRemove.isEmpty()) return true; + if (toRemove.isEmpty()) { + return true; + } - // Verify we have enough of each item for (Map.Entry entry : toRemove.entrySet()) { - Long currentAmount = consolidatedItems.getOrDefault(entry.getKey(), 0L); - if (currentAmount < entry.getValue()) { + if (consolidatedItems.getOrDefault(entry.getKey(), 0L) < entry.getValue()) { return false; } } - // Perform removals all at once - boolean updated = false; for (Map.Entry entry : toRemove.entrySet()) { - ItemSignature sig = entry.getKey(); - long amountToRemove = entry.getValue(); - - consolidatedItems.computeIfPresent(sig, (key, current) -> { - long newAmount = current - amountToRemove; - return newAmount <= 0 ? null : newAmount; + consolidatedItems.computeIfPresent(entry.getKey(), (key, current) -> { + long remaining = current - entry.getValue(); + return remaining <= 0 ? null : remaining; }); - - updated = true; } - if (updated) { - displayCacheDirty = true; - metricsCacheDirty = true; - sortedEntriesCache = null; // Invalidate sorted entries cache - } + 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 Int2ObjectMap getDisplayPage(int page, int pageSize) { + if (pageSize <= 0) { + return Int2ObjectMaps.emptyMap(); } - // Clear the cache for a fresh rebuild but reuse the existing map - displayInventoryCache.clear(); - - if (consolidatedItems.isEmpty()) { - displayCacheDirty = false; - usedSlotsCache = 0; - return Collections.emptyMap(); - } - - // 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().getTemplateRef().getType() == preferredSortMaterial; - boolean e2Preferred = e2.getKey().getTemplateRef().getType() == preferredSortMaterial; - - 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())); - } - } - - // Process items directly to the display inventory - int currentSlot = 0; - - for (Map.Entry entry : sortedEntriesCache) { - if (currentSlot >= maxSlots) break; - - ItemSignature sig = entry.getKey(); - long totalAmount = entry.getValue(); - ItemStack templateItem = sig.getTemplateRef(); - int maxStackSize = templateItem.getMaxStackSize(); - - // Create as many stacks as needed for this item type - while (totalAmount > 0 && currentSlot < maxSlots) { - int stackSize = (int) Math.min(totalAmount, maxStackSize); - - // Create the display item only once per slot - ItemStack displayItem = templateItem.clone(); - displayItem.setAmount(stackSize); - - // Store in cache - displayInventoryCache.put(currentSlot, displayItem); - - totalAmount -= stackSize; - currentSlot++; - } - } - - // 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() { @@ -299,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().getTemplateRef().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; } /** @@ -348,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; } @@ -357,8 +184,8 @@ public void sortItems(org.bukkit.Material preferredMaterial) { this.sortedEntriesCache = consolidatedItems.entrySet().stream() .sorted((e1, e2) -> { // Use getTemplateRef() to avoid cloning - we only need to read the type - boolean e1Preferred = e1.getKey().getTemplateRef().getType() == preferredMaterial; - boolean e2Preferred = e2.getKey().getTemplateRef().getType() == preferredMaterial; + boolean e1Preferred = e1.getKey().getMaterial() == preferredMaterial; + boolean e2Preferred = e2.getKey().getMaterial() == preferredMaterial; if (e1Preferred && !e2Preferred) return -1; if (!e1Preferred && e2Preferred) return 1; @@ -373,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(); } - this.maxSlots = newMaxSlots; + int safeStart = Math.max(0, startSlot); + int sectionLimit = Math.min(maxResults, maxSlots - safeStart); + if (sectionLimit <= 0) { + return Int2ObjectMaps.emptyMap(); + } + + Int2ObjectOpenHashMap section = new Int2ObjectOpenHashMap<>(Math.min(sectionLimit, 45)); + List> sortedEntries = getSortedEntries(); + + int currentGlobalSlot = 0; + int relativeSlot = 0; + + for (Map.Entry entry : sortedEntries) { + if (relativeSlot >= sectionLimit || currentGlobalSlot >= maxSlots) { + break; + } - // Mark caches as dirty since slot count changed - this.displayCacheDirty = true; + ItemSignature sig = entry.getKey(); + int maxStackSize = sig.getMaxStackSize(); + if (maxStackSize <= 0) { + continue; + } - // 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 + 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())); } -} \ No newline at end of file +} 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 4221c791..70d2afcf 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 @@ -5,6 +5,7 @@ import github.nighter.smartspawner.api.events.SpawnerSellEvent; import github.nighter.smartspawner.language.MessageService; import github.nighter.smartspawner.spawner.gui.synchronization.SpawnerGuiViewManager; +import github.nighter.smartspawner.spawner.properties.ItemSignature; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.spawner.properties.VirtualInventory; @@ -83,7 +84,7 @@ public void sellAllItems(Player player, SpawnerData spawner, Runnable onComplete spawnerGuiViewManager.closeAllViewersInventory(spawner); // Lightweight snapshot – safe because isSelling prevents concurrent inventory changes - final Map itemSnapshot = virtualInv.getConsolidatedItems(); + final Map itemSnapshot = virtualInv.getConsolidatedItems(); final double accumulatedValue = spawner.getAccumulatedSellValue(); final Location spawnerLocation = spawner.getSpawnerLocation(); @@ -139,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(); @@ -178,33 +183,38 @@ 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()) { - ItemStack templateRef = entry.getKey().getTemplateRef(); - long amount = entry.getValue(); - int maxStackSize = templateRef.getMaxStackSize(); + for (Map.Entry entry : consolidatedItems.entrySet()) { + totalItemsSold += entry.getValue(); + } - totalItemsSold += amount; + return new SellResult(totalValue, totalItemsSold, consolidatedItems); + } - int stacksNeeded = (int) Math.ceil((double) amount / maxStackSize); - itemsToRemove.ensureCapacity(itemsToRemove.size() + stacksNeeded); + private List toApiItemStacks(Map items) { + if (items == null || items.isEmpty()) { + return Collections.emptyList(); + } - long remaining = amount; - while (remaining > 0) { - ItemStack stack = templateRef.clone(); - stack.setAmount((int) Math.min(remaining, maxStackSize)); - itemsToRemove.add(stack); - remaining -= stack.getAmount(); + List apiItems = new ArrayList<>(items.size()); + + for (Map.Entry entry : items.entrySet()) { + ItemSignature signature = entry.getKey(); + Long amount = entry.getValue(); + if (signature == null || amount == null || amount <= 0) { + continue; } + + 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/spawner/utils/ItemStackSerializer.java b/core/src/main/java/github/nighter/smartspawner/spawner/utils/ItemStackSerializer.java index 23db553b..701f6c5b 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/utils/ItemStackSerializer.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/utils/ItemStackSerializer.java @@ -1,6 +1,6 @@ package github.nighter.smartspawner.spawner.utils; -import github.nighter.smartspawner.spawner.properties.VirtualInventory; +import github.nighter.smartspawner.spawner.properties.ItemSignature; import lombok.Getter; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; @@ -38,13 +38,14 @@ public void addPotionArrow(PotionType potionType, int count) { } } - public static List serializeInventory(Map items) { + public static List serializeInventory(Map items) { Map groupedItems = new HashMap<>(); - for (Map.Entry entry : items.entrySet()) { + for (Map.Entry entry : items.entrySet()) { // Use getTemplateRef() to avoid cloning - we only need to read properties - ItemStack template = entry.getKey().getTemplateRef(); - Material material = template.getType(); + ItemSignature signature = entry.getKey(); + ItemStack template = signature.getTemplateRef(); + Material material = signature.getMaterial(); ItemGroup group = groupedItems.computeIfAbsent(material, ItemGroup::new); if (material == Material.TIPPED_ARROW) { @@ -209,4 +210,4 @@ public static boolean isDestructibleItem(Material material) { || name.equals("WARPED_FUNGUS_ON_A_STICK") || name.equals("MACE"); } -} \ No newline at end of file +} 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 +} diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index bed53b70..3c188e43 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -156,6 +156,23 @@ hopper: check_delay: 3s # Time between collection checks (see time format guide above) stack_per_transfer: 5 # Number of item stacks transferred in one operation (max 5) +#--------------------------------------------------- +# Loot Generation Settings +#--------------------------------------------------- +# Configuration for internal loot generation algorithms +loot_generation: + # Enables the optimized hybrid loot generation system + # + # When enabled, SmartSpawner dynamically switches between: + # - Exact O(n) simulation for rare drops or small mob batches (binomial distribution) + # - Fast O(1) expected-value approximation for large mob batches (binomial approximation) + # + # This significantly improves performance with millions of stacked spawners + # while preserving realistic loot distribution and rare drop accuracy. + # + # Recommended: true + optimized_generation: false + #--------------------------------------------------- # Bedrock Player Support #---------------------------------------------------