diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/gui/synchronization/SpawnerGuiViewManager.java b/core/src/main/java/github/nighter/smartspawner/spawner/gui/synchronization/SpawnerGuiViewManager.java index 718559e5..65a9f65d 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/gui/synchronization/SpawnerGuiViewManager.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/gui/synchronization/SpawnerGuiViewManager.java @@ -90,6 +90,8 @@ public class SpawnerGuiViewManager implements Listener { // Cache for storage title format to avoid repeated language lookups private String cachedStorageTitleFormat = null; + + private static final long PRE_GENERATION_THRESHOLD = 2000L; // Static class to hold viewer info more efficiently private static class SpawnerViewerInfo { @@ -1320,7 +1322,6 @@ private String updateExistingTimerLine(String line, String newTimeDisplay) { } private long calculateTimeUntilNextSpawn(SpawnerData spawner) { - // Cache spawn delay calculation outside of lock long cachedDelay = spawner.getCachedSpawnDelay(); if (cachedDelay == 0) { cachedDelay = spawner.getSpawnDelay() * 50L; @@ -1331,45 +1332,75 @@ private long calculateTimeUntilNextSpawn(SpawnerData spawner) { long lastSpawnTime = spawner.getLastSpawnTime(); long timeElapsed = currentTime - lastSpawnTime; - // Check if spawner is truly inactive (not just in initial stopped state) - // This mirrors the logic from SpawnerMenuUI.calculateInitialTimerValue() to ensure consistency - // between initial display and ongoing timer updates boolean isSpawnerInactive = !spawner.getSpawnerActive() || - (spawner.getSpawnerStop().get() && timeElapsed > cachedDelay * 2); // Only inactive if stopped for more than 2 cycles + (spawner.getSpawnerStop().get() && timeElapsed > cachedDelay * 2); if (isSpawnerInactive) { + spawner.clearPreGeneratedLoot(); return -1; } long timeUntilNextSpawn = cachedDelay - timeElapsed; - - // Ensure we don't go below 0 or above the delay timeUntilNextSpawn = Math.max(0, Math.min(timeUntilNextSpawn, cachedDelay)); + + if (timeUntilNextSpawn > 0 && timeUntilNextSpawn <= PRE_GENERATION_THRESHOLD) { + if (!spawner.isPreGenerating() && !spawner.hasPreGeneratedLoot()) { + if (!spawner.getSpawnerActive() || spawner.getSpawnerStop().get()) { + return timeUntilNextSpawn; + } + + spawner.setPreGenerating(true); + + Location spawnerLocation = spawner.getSpawnerLocation(); + if (spawnerLocation != null) { + Scheduler.runLocationTask(spawnerLocation, () -> { + if (!spawner.getSpawnerActive() || spawner.getSpawnerStop().get()) { + spawner.setPreGenerating(false); + return; + } + + plugin.getSpawnerLootGenerator().preGenerateLoot(spawner, (items, experience) -> { + spawner.storePreGeneratedLoot(items, experience); + spawner.setPreGenerating(false); + }); + }); + } + } + } - // If the timer has expired, handle spawn timing if (timeUntilNextSpawn <= 0) { try { - // Try to acquire dataLock with timeout to prevent deadlock if (spawner.getDataLock().tryLock(100, TimeUnit.MILLISECONDS)) { try { - // Re-check timing after acquiring lock currentTime = System.currentTimeMillis(); lastSpawnTime = spawner.getLastSpawnTime(); timeElapsed = currentTime - lastSpawnTime; if (timeElapsed >= cachedDelay) { - // Update last spawn time to current time for next cycle - spawner.setLastSpawnTime(currentTime); - - // Get the spawner location to schedule on the right region + if (!spawner.getSpawnerActive() || spawner.getSpawnerStop().get()) { + spawner.clearPreGeneratedLoot(); + return cachedDelay; + } + Location spawnerLocation = spawner.getSpawnerLocation(); if (spawnerLocation != null) { - // Schedule activation on the appropriate region thread for Folia compatibility Scheduler.runLocationTask(spawnerLocation, () -> { - plugin.getRangeChecker().activateSpawner(spawner); + if (!spawner.getSpawnerActive() || spawner.getSpawnerStop().get()) { + spawner.clearPreGeneratedLoot(); + return; + } + + if (spawner.hasPreGeneratedLoot()) { + List items = spawner.getAndClearPreGeneratedItems(); + int exp = spawner.getAndClearPreGeneratedExperience(); + plugin.getSpawnerLootGenerator().addPreGeneratedLoot(spawner, items, exp); + } else { + plugin.getSpawnerLootGenerator().spawnLootToSpawner(spawner); + } }); } - return cachedDelay; // Start new cycle with full delay + + return cachedDelay; } return cachedDelay - timeElapsed; @@ -1377,13 +1408,11 @@ private long calculateTimeUntilNextSpawn(SpawnerData spawner) { spawner.getDataLock().unlock(); } } else { - // If can't acquire lock, return minimal time to try again soon - return 1000; // 1 second + return 1000; } } catch (InterruptedException e) { - // Handle interruption Thread.currentThread().interrupt(); - return 1000; // 1 second + return 1000; } } 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 e28d0597..c16a54bc 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 @@ -65,13 +65,9 @@ public void spawnLootToSpawner(SpawnerData spawner) { final AtomicInteger maxSlots; try { - long lastSpawnTime = spawner.getLastSpawnTime(); - long spawnDelay = spawner.getSpawnDelay(); - - if (currentTime - lastSpawnTime < spawnDelay) { - return; - } - + // 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()); @@ -366,4 +362,198 @@ 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: + *

+ * + * @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); + return; + } + + try { + boolean dataLockAcquired = false; + try { + dataLockAcquired = spawner.getDataLock().tryLock(50, java.util.concurrent.TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + callback.onLootGenerated(Collections.emptyList(), 0); + return; + } + + if (!dataLockAcquired) { + callback.onLootGenerated(Collections.emptyList(), 0); + return; + } + + final int minMobs; + final int maxMobs; + final boolean atCapacity; + + try { + int usedSlots = spawner.getVirtualInventory().getUsedSlots(); + int maxSlots = spawner.getMaxSpawnerLootSlots(); + atCapacity = usedSlots >= maxSlots && spawner.getSpawnerExp() >= spawner.getMaxStoredExp(); + + if (atCapacity) { + callback.onLootGenerated(Collections.emptyList(), 0); + return; + } + + minMobs = spawner.getMinMobs(); + maxMobs = spawner.getMaxMobs(); + } finally { + spawner.getDataLock().unlock(); + } + + Scheduler.runTaskAsync(() -> { + LootResult loot = generateLoot(minMobs, maxMobs, spawner); + callback.onLootGenerated( + loot.getItems() != null ? new ArrayList<>(loot.getItems()) : Collections.emptyList(), + loot.getExperience() + ); + }); + } finally { + spawner.getLootGenerationLock().unlock(); + } + } + + /** + * Adds pre-generated loot to spawner instantly when timer expires. + * + *

This method: + *

+ * + *

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 experience Pre-generated experience amount + */ + public void addPreGeneratedLoot(SpawnerData spawner, List items, int experience) { + if ((items == null || items.isEmpty()) && experience == 0) { + return; + } + + Location spawnerLocation = spawner.getSpawnerLocation(); + if (spawnerLocation == null) { + return; + } + + Scheduler.runLocationTask(spawnerLocation, () -> { + if (!spawner.getLootGenerationLock().tryLock()) { + return; + } + + try { + boolean dataLockAcquired = false; + try { + dataLockAcquired = spawner.getDataLock().tryLock(50, java.util.concurrent.TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + if (!dataLockAcquired) { + return; + } + + final long spawnTime = System.currentTimeMillis(); + final boolean capacityCheck; + + try { + int usedSlots = spawner.getVirtualInventory().getUsedSlots(); + int maxSlots = spawner.getMaxSpawnerLootSlots(); + capacityCheck = usedSlots >= maxSlots && spawner.getSpawnerExp() >= spawner.getMaxStoredExp(); + + if (capacityCheck) { + return; + } + } finally { + spawner.getDataLock().unlock(); + } + + Scheduler.runTaskAsync(() -> { + boolean changed = false; + + if (experience > 0 && spawner.getSpawnerExp() < spawner.getMaxStoredExp()) { + int currentExp = spawner.getSpawnerExp(); + int maxExp = spawner.getMaxStoredExp(); + int newExp = Math.min(currentExp + experience, maxExp); + + if (newExp != currentExp) { + spawner.setSpawnerExp(newExp); + changed = true; + } + } + + if (items != null && !items.isEmpty()) { + List validItems = new ArrayList<>(); + for (ItemStack item : items) { + if (item != null && item.getType() != Material.AIR) { + validItems.add(item.clone()); + } + } + + if (!validItems.isEmpty()) { + spawner.addItemsAndUpdateSellValue(validItems); + changed = true; + } + } + + if (!changed) { + return; + } + + if (spawner.getDataLock().tryLock()) { + try { + spawner.setLastSpawnTime(spawnTime); + } finally { + spawner.getDataLock().unlock(); + } + } + + spawner.updateCapacityStatus(); + handleGuiUpdates(spawner); + spawnerManager.markSpawnerModified(spawner.getSpawnerId()); + }); + } finally { + spawner.getLootGenerationLock().unlock(); + } + }); + } + + /** + * Callback interface for asynchronous loot pre-generation. + * Invoked when loot generation completes with the generated items and experience. + */ + @FunctionalInterface + public interface LootGenerationCallback { + /** + * Called when loot generation completes. + * + * @param items Generated items list (never null, may be empty) + * @param experience Generated experience amount + */ + void onLootGenerated(List items, int 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 3870e207..e9092986 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 @@ -128,19 +128,23 @@ private void handleSpawnerStateChange(SpawnerData spawner, boolean shouldStop) { public void activateSpawner(SpawnerData spawner) { deactivateSpawner(spawner); - plugin.getLogger().info("Activating spawner " + spawner.getSpawnerId() + " for loot spawning."); + + // Check if spawner is actually active before starting countdown + if (!spawner.getSpawnerActive()) { + return; + } // Set lastSpawnTime to current time to start countdown immediately - // This ensures timer shows full delay countdown when spawner activates long currentTime = System.currentTimeMillis(); spawner.setLastSpawnTime(currentTime); + // Timer manages the spawner active state - actual loot spawning triggered by SpawnerGuiViewManager Scheduler.Task task = Scheduler.runTaskTimer(() -> { - if (!spawner.getSpawnerStop().get()) { - plugin.getLogger().info("Spawning loot for spawner " + spawner.getSpawnerId()); - spawnerLootGenerator.spawnLootToSpawner(spawner); + // Verify spawner is still active on each tick + if (!spawner.getSpawnerActive() || spawner.getSpawnerStop().get()) { + return; } - }, spawner.getSpawnDelay(), spawner.getSpawnDelay()); // Start after one delay period + }, spawner.getSpawnDelay(), spawner.getSpawnDelay()); spawnerTasks.put(spawner.getSpawnerId(), task); 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 4496dd34..d0bdd274 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 @@ -112,6 +112,11 @@ public class SpawnerData { // Sort preference for spawner storage @Getter @Setter private Material preferredSortItem; + + // CRITICAL: Pre-generated loot storage for better UX - access must be synchronized via lootGenerationLock + private volatile List preGeneratedItems; + private volatile int preGeneratedExperience; + private volatile boolean isPreGenerating; public SpawnerData(String id, Location location, EntityType type, SmartSpawner plugin) { super(); @@ -603,4 +608,39 @@ public boolean removeItemsAndUpdateSellValue(List items) { return removed; } + + public synchronized void storePreGeneratedLoot(List items, int experience) { + this.preGeneratedItems = items; + this.preGeneratedExperience = experience; + } + + public synchronized List getAndClearPreGeneratedItems() { + List items = preGeneratedItems; + preGeneratedItems = null; + return items; + } + + public synchronized int getAndClearPreGeneratedExperience() { + int exp = preGeneratedExperience; + preGeneratedExperience = 0; + return exp; + } + + public synchronized boolean hasPreGeneratedLoot() { + return (preGeneratedItems != null && !preGeneratedItems.isEmpty()) || preGeneratedExperience > 0; + } + + public synchronized void setPreGenerating(boolean generating) { + this.isPreGenerating = generating; + } + + public synchronized boolean isPreGenerating() { + return isPreGenerating; + } + + public synchronized void clearPreGeneratedLoot() { + preGeneratedItems = null; + preGeneratedExperience = 0; + isPreGenerating = false; + } }