Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -1331,59 +1332,87 @@ 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<ItemStack> 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;
} finally {
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;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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.
*
* <p>This method:
* <ul>
* <li>Checks spawner capacity before generation</li>
* <li>Generates loot asynchronously to avoid blocking</li>
* <li>Invokes callback with generated items and experience</li>
* <li>Handles thread-safety with proper locking</li>
* </ul>
*
* @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.
*
* <p>This method:
* <ul>
* <li>Validates pre-generated loot is not empty</li>
* <li>Rechecks capacity (may have changed since pre-generation)</li>
* <li>Adds items and experience to spawner</li>
* <li>Updates lastSpawnTime to maintain cycle timing</li>
* <li>Triggers GUI updates and marks spawner for persistence</li>
* </ul>
*
* <p><b>Thread Safety:</b> 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<ItemStack> 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<ItemStack> 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<ItemStack> items, int experience);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading