diff --git a/AdvancedCore/pom.xml b/AdvancedCore/pom.xml index 383ffe5bbe..5fc0f83530 100644 --- a/AdvancedCore/pom.xml +++ b/AdvancedCore/pom.xml @@ -91,7 +91,7 @@ 3.6.1 false - true + false @@ -131,6 +131,33 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + default-test + + + **/PackagedArtifactTest.java + + + + + packaged-artifact-test + package + test + + **/PackagedArtifactTest.java + ${project.build.directory}/packaged-artifact-reports + + ${project.build.directory}/${project.name}.jar + + + + + diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java index 448e0b14f7..a46ba56337 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java @@ -6,10 +6,14 @@ import java.util.Map.Entry; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.logging.Level; @@ -21,6 +25,7 @@ import org.bukkit.inventory.ItemStack; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.data.ServerData; import com.bencodez.simpleapi.messages.MessageAPI; @@ -36,8 +41,18 @@ public class FullInventoryHandler { @Getter private final ConcurrentHashMap> items = new ConcurrentHashMap<>(); + /* + * Replay-aware delivery cannot place an overflow into the ordinary pending map + * until its snapshot has been written. The ordinary periodic sweep is allowed + * to consume that map, so doing so earlier makes a failed save indistinguishable + * from a delivery that has already happened and a replay can duplicate items. + */ + private final ConcurrentHashMap replayOverflowReservations = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> replayOverflowCompletions = new ConcurrentHashMap<>(); + private final AdvancedCorePlugin plugin; private final ReentrantReadWriteLock deliveryLock = new ReentrantReadWriteLock(true); + private final AtomicBoolean shuttingDown = new AtomicBoolean(); @Getter private ScheduledExecutorService timer; @@ -47,6 +62,19 @@ public class FullInventoryHandler { @Getter private final ConcurrentHashMap lastMessageTime = new ConcurrentHashMap<>(); + private static final class ReservedOverflow { + private final UUID playerId; + private final ArrayList items; + + private ReservedOverflow(UUID playerId, Collection items) { + this.playerId = playerId; + this.items = new ArrayList<>(); + for (ItemStack item : items) { + if (item != null) this.items.add(item); + } + } + } + public FullInventoryHandler(AdvancedCorePlugin plugin) { this.plugin = plugin; loadTimer(); @@ -67,6 +95,7 @@ public void add(UUID uuid, ItemStack item) { } public void check() { + if (shuttingDown.get()) return; if (!Bukkit.isPrimaryThread()) { plugin.getBukkitScheduler().runTask(plugin, this::schedulePendingPlayerChecks); return; @@ -75,18 +104,131 @@ public void check() { } public void check(Player player) { - if (player == null) { + if (player == null || shuttingDown.get()) { return; } plugin.getBukkitScheduler().runTask(plugin, () -> checkOwnedPlayer(player), player); } public void giveItem(Player player, ItemStack... item) { + scheduleItemDelivery(player, item, null); + } + + /** + * Gives items on the owning player scheduler and completes after the inventory + * mutation, including full-inventory handling, has finished. The established + * void API remains fire-and-forget; replay-aware callers use this boundary so + * they never checkpoint a queued delivery as completed. + */ + public CompletionStage giveItemAsync(Player player, ItemStack... item) { + CompletableFuture completion = new CompletableFuture<>(); + scheduleItemDelivery(player, item, completion); + return completion; + } + + private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFuture completion) { if (player == null || item == null || item.length == 0) { + if (completion != null) completion.complete(null); return; } + if (completion != null && shuttingDown.get()) { + completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Full-inventory handler is shutting down before item delivery")); + return; + } + UUID playerId = null; + if (completion != null) { + try { + playerId = player.getUniqueId(); + if (playerId == null) { + completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Item delivery player has no UUID")); + return; + } + } catch (Throwable failure) { + completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Unable to resolve item delivery player before dispatch", failure)); + return; + } + } + final UUID deliveryPlayerId = playerId; + final String reservationId = completion == null ? null : UUID.randomUUID().toString(); ItemStack[] itemsToGive = item.clone(); - plugin.getBukkitScheduler().runTask(plugin, () -> giveItemOwnedPlayer(player, itemsToGive), player); + AtomicBoolean deliveryClaimed = new AtomicBoolean(); + Runnable delivery = () -> { + if (!deliveryClaimed.compareAndSet(false, true)) return; + boolean replayDeliveryLocked = false; + try { + if (completion != null) { + deliveryLock.readLock().lock(); + replayDeliveryLocked = true; + } + if (completion != null) validateReplayDeliveryTarget(player, deliveryPlayerId); + if (completion != null) replayOverflowCompletions.put(reservationId, completion); + boolean pendingOverflow = giveItemOwnedPlayer(player, itemsToGive, reservationId); + if (completion != null) { + if (pendingOverflow) { + persistReservedOverflowAsync(reservationId).whenComplete((ignored, failure) -> { + if (failure == null) completeReplayOverflow(reservationId, completion); + else completeStartedOverflowWithFallback(player, deliveryPlayerId, reservationId, completion); + }); + } else { + replayOverflowCompletions.remove(reservationId, completion); + completion.complete(null); + } + } + } catch (Throwable failure) { + if (completion != null) { + replayOverflowCompletions.remove(reservationId, completion); + completion.completeExceptionally(failure); + } + else rethrowDeliveryFailure(failure); + } finally { + if (replayDeliveryLocked) deliveryLock.readLock().unlock(); + } + }; + try { + plugin.getBukkitScheduler().runTask(plugin, delivery, player); + } catch (Throwable failure) { + deliveryClaimed.set(true); + if (completion != null) completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Scheduler rejected item delivery before dispatch", failure)); + else rethrowDeliveryFailure(failure); + } + if (completion == null) return; + CompletableFuture.delayedExecutor(getItemDeliveryTimeoutMillis(), TimeUnit.MILLISECONDS).execute(() -> { + if (deliveryClaimed.compareAndSet(false, true)) { + completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Timed out waiting for item delivery", new TimeoutException())); + } + }); + } + + /** Bounds a replay-aware delivery; timeout claims the delivery to prevent a duplicate retry. */ + protected long getItemDeliveryTimeoutMillis() { + return TimeUnit.SECONDS.toMillis(30); + } + + /** + * Runs on the owning player scheduler immediately before a replay-aware inventory + * mutation. A reconnect creates a different player entity, so require both the + * captured UUID and entity identity to remain current; a retry can then safely + * schedule delivery for the live entity. + */ + private void validateReplayDeliveryTarget(Player player, UUID playerId) { + if (!plugin.isEnabled()) { + throw AdvancedCoreUser.replayActionNotStarted("Plugin disabled before item delivery"); + } + Player current = Bukkit.getPlayer(playerId); + if (current != player || !current.isOnline()) { + throw AdvancedCoreUser.replayActionNotStarted("Player became unavailable before item delivery"); + } + } + + private static void rethrowDeliveryFailure(Throwable failure) { + if (failure instanceof RuntimeException) throw (RuntimeException) failure; + if (failure instanceof Error) throw (Error) failure; + throw new IllegalStateException("Failed to schedule item delivery", failure); } public synchronized void loadTimer() { @@ -103,10 +245,15 @@ public synchronized void loadTimer() { } public synchronized void shutdown() { + shuttingDown.set(true); if (checkTask != null) { checkTask.cancel(false); checkTask = null; } + // Flush accepted replay reservations while their completion stages can still + // advance the outer reward checkpoint. Stopping the executor first can discard + // an accepted persistence task and later replay the already-inserted items. + saveDurably(); if (timer != null) { timer.shutdownNow(); } @@ -120,42 +267,205 @@ public synchronized void shutdown() { * the temporary remove/re-add state of an in-flight inventory check. */ public void save() { + saveDurably(); + } + + /** Saves pending items and reports whether the disk snapshot completed. */ + public boolean saveDurably() { + HashMap reservations; + boolean saved; + deliveryLock.writeLock().lock(); + try { + reservations = new HashMap<>(replayOverflowReservations); + if (!savePendingItemsLocked(reservations.values())) return false; + for (Entry entry : reservations.entrySet()) { + promoteReservedOverflowLocked(entry.getKey(), entry.getValue()); + } + saved = true; + } finally { + deliveryLock.writeLock().unlock(); + } + if (saved) { + for (String reservationId : reservations.keySet()) completeReplayOverflow(reservationId, null); + } + return true; + } + + /** Persists one replay reservation before making it visible to ordinary pending checks. */ + private CompletionStage persistReservedOverflowAsync(String reservationId) { + CompletableFuture persisted = new CompletableFuture<>(); + try { + timer.execute(() -> { + if (persistReservedOverflow(reservationId)) persisted.complete(null); + else persisted.completeExceptionally( + new IllegalStateException("Unable to persist full-inventory reward overflow")); + }); + } catch (Throwable failure) { + persisted.completeExceptionally(failure); + } + return persisted; + } + + private boolean persistReservedOverflow(String reservationId) { deliveryLock.writeLock().lock(); + try { + ReservedOverflow reservation = replayOverflowReservations.get(reservationId); + if (reservation == null || reservation.items.isEmpty()) return true; + if (!savePendingItemsLocked(java.util.List.of(reservation))) return false; + promoteReservedOverflowLocked(reservationId, reservation); + return true; + } finally { + deliveryLock.writeLock().unlock(); + } + } + + /** + * A save failure happens after Bukkit has already accepted part of this reward. + * Complete the replay action only after an owner-thread fallback consumes every + * reserved item or a later retry persists the remainder. Reserved items stay + * excluded from the normal delivery sweep while either handoff is pending. + */ + private void completeStartedOverflowWithFallback(Player player, UUID playerId, String reservationId, + CompletableFuture completion) { + Runnable fallback = () -> { + if (dropReservedOverflowOwnedPlayer(player, playerId, reservationId)) { + completeReplayOverflow(reservationId, completion); + } + else scheduleReservedOverflowPersistenceRetry(reservationId, completion); + }; + try { + plugin.getBukkitScheduler().runTask(plugin, fallback, player); + } catch (Throwable failure) { + // The mutation has already started, so neither acknowledge it nor fail it as + // replayable. Keep the stage pending until the reservation is durable. + scheduleReservedOverflowPersistenceRetry(reservationId, completion); + } + } + + /** Runs only on the player owner scheduler. */ + private boolean dropReservedOverflowOwnedPlayer(Player player, UUID playerId, String reservationId) { + if (player == null || playerId == null || Bukkit.getPlayer(playerId) != player || !player.isOnline()) return false; + deliveryLock.writeLock().lock(); + try { + ReservedOverflow reservation = replayOverflowReservations.get(reservationId); + if (reservation == null) return true; + if (!playerId.equals(reservation.playerId)) return false; + ArrayList remaining = new ArrayList<>(); + for (ItemStack item : reservation.items) { + try { + if (player.getWorld().dropItem(player.getLocation(), item) == null) remaining.add(item); + } catch (Throwable failure) { + remaining.add(item); + } + } + if (remaining.isEmpty()) replayOverflowReservations.remove(reservationId, reservation); + else replayOverflowReservations.put(reservationId, new ReservedOverflow(playerId, remaining)); + return remaining.isEmpty(); + } finally { + deliveryLock.writeLock().unlock(); + } + } + + private void scheduleReservedOverflowPersistenceRetry(String reservationId, CompletableFuture completion) { + if (!replayOverflowReservations.containsKey(reservationId)) { + completeReplayOverflow(reservationId, completion); + return; + } + try { + timer.schedule(() -> { + if (persistReservedOverflow(reservationId)) completeReplayOverflow(reservationId, completion); + else scheduleReservedOverflowPersistenceRetry(reservationId, completion); + }, 30, TimeUnit.SECONDS); + } catch (Throwable ignored) { + // Shutdown may reject this retry. Leave the completion pending: acknowledging + // an in-memory-only remainder would lose it from the durable reward replay. + } + } + + private void completeReplayOverflow(String reservationId, CompletableFuture fallbackCompletion) { + CompletableFuture completion = replayOverflowCompletions.remove(reservationId); + if (completion == null) completion = fallbackCompletion; + if (completion != null) completion.complete(null); + } + + /** Caller holds the write lock. Includes only the reservations in this durable handoff. */ + private boolean savePendingItemsLocked(Collection reservedOverflows) { + FileConfiguration data = null; + YamlConfiguration previous = null; try { ServerData serverData = plugin.getServerDataFile(); - if (serverData == null || serverData.getData() == null) { - return; + if (serverData == null || serverData.getData() == null) return false; + + HashMap> snapshotItems = new HashMap<>(); + for (Entry> entry : items.entrySet()) { + snapshotItems.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + for (ReservedOverflow reservedOverflow : reservedOverflows) { + if (!reservedOverflow.items.isEmpty()) { + snapshotItems.computeIfAbsent(reservedOverflow.playerId, ignored -> new ArrayList<>()) + .addAll(reservedOverflow.items); + } } YamlConfiguration snapshot = new YamlConfiguration(); long now = System.currentTimeMillis(); - for (Entry> entry : items.entrySet()) { - ArrayList pending = new ArrayList<>(entry.getValue()); + for (Entry> entry : snapshotItems.entrySet()) { String basePath = "FullInventory." + entry.getKey(); - for (int i = 0; i < pending.size(); i++) { - snapshot.set(basePath + ".Items." + i, pending.get(i)); + for (int i = 0; i < entry.getValue().size(); i++) { + snapshot.set(basePath + ".Items." + i, entry.getValue().get(i)); } snapshot.set(basePath + ".Time", now); } - FileConfiguration data = serverData.getData(); + data = serverData.getData(); + previous = copySection(data, "FullInventory"); data.set("FullInventory", null); ConfigurationSection replacement = snapshot.getConfigurationSection("FullInventory"); if (replacement != null) { for (String path : replacement.getKeys(true)) { - if (!replacement.isConfigurationSection(path)) { - data.set("FullInventory." + path, replacement.get(path)); - } + if (!replacement.isConfigurationSection(path)) data.set("FullInventory." + path, replacement.get(path)); } } serverData.saveData(); + return true; } catch (Exception e) { - plugin.getLogger().log(Level.WARNING, "Failed to save pending full-inventory items", e); - } finally { - deliveryLock.writeLock().unlock(); + if (data != null && previous != null) replaceSection(data, "FullInventory", previous); + try { + plugin.getLogger().log(Level.WARNING, "Failed to save pending full-inventory items", e); + } catch (Throwable ignored) { + // A partially initialized plugin can still retain the reservation for retry. + } + return false; + } + } + + private static YamlConfiguration copySection(ConfigurationSection source, String path) { + YamlConfiguration copy = new YamlConfiguration(); + ConfigurationSection section = source.getConfigurationSection(path); + if (section == null) return copy; + for (String child : section.getKeys(true)) { + if (!section.isConfigurationSection(child)) copy.set(child, section.get(child)); + } + return copy; + } + + private static void replaceSection(ConfigurationSection target, String path, YamlConfiguration replacement) { + target.set(path, null); + for (String child : replacement.getKeys(true)) { + if (!replacement.isConfigurationSection(child)) target.set(path + "." + child, replacement.get(child)); } } + /** Caller holds the write lock. */ + private void promoteReservedOverflowLocked(String reservationId, ReservedOverflow reservation) { + if (!replayOverflowReservations.remove(reservationId, reservation)) return; + items.compute(reservation.playerId, (key, current) -> { + ArrayList merged = current == null ? new ArrayList<>() : new ArrayList<>(current); + merged.addAll(reservation.items); + return merged; + }); + } + public void startup() { try { if (plugin.getServerDataFile() == null || plugin.getServerDataFile().getData() == null) { @@ -218,6 +528,7 @@ private void addItems(UUID uuid, Collection itemsToAdd) { private void checkOwnedPlayer(Player player) { deliveryLock.readLock().lock(); try { + if (shuttingDown.get()) return; UUID uuid = player.getUniqueId(); ArrayList pending = items.remove(uuid); if (pending == null || pending.isEmpty()) { @@ -240,36 +551,60 @@ private void checkOwnedPlayer(Player player) { } } - private void giveItemOwnedPlayer(Player player, ItemStack[] item) { + private boolean giveItemOwnedPlayer(Player player, ItemStack[] item, String reservationId) { deliveryLock.readLock().lock(); try { + if (reservationId != null && shuttingDown.get()) { + throw AdvancedCoreUser.replayActionNotStarted( + "Full-inventory handler shut down before queued item delivery began"); + } HashMap excess = player.getInventory().addItem(item); if (excess.isEmpty()) { player.updateInventory(); - return; + return false; } boolean dropItems = plugin.getOptions().isDropOnFullInv(); - for (ItemStack extra : excess.values()) { - if (dropItems) { - player.getWorld().dropItem(player.getLocation(), extra); + if (dropItems) { + if (reservationId == null) { + for (ItemStack extra : excess.values()) player.getWorld().dropItem(player.getLocation(), extra); } else { - add(player.getUniqueId(), extra); + ReservedOverflow reservation = new ReservedOverflow(player.getUniqueId(), excess.values()); + replayOverflowReservations.put(reservationId, reservation); + ArrayList undropped = new ArrayList<>(); + for (ItemStack extra : reservation.items) { + try { + if (player.getWorld().dropItem(player.getLocation(), extra) == null) undropped.add(extra); + } catch (Throwable failure) { + undropped.add(extra); + } + } + if (undropped.isEmpty()) replayOverflowReservations.remove(reservationId, reservation); + else replayOverflowReservations.put(reservationId, + new ReservedOverflow(player.getUniqueId(), undropped)); } + } else if (reservationId != null) { + replayOverflowReservations.put(reservationId, + new ReservedOverflow(player.getUniqueId(), excess.values())); + } else { + for (ItemStack extra : excess.values()) add(player.getUniqueId(), extra); } if (shouldSendMessage(player.getUniqueId())) { sendMessage(player); } player.updateInventory(); + return reservationId != null && replayOverflowReservations.containsKey(reservationId); } finally { deliveryLock.readLock().unlock(); } } private void schedulePendingPlayerChecks() { + if (shuttingDown.get()) return; long now = System.currentTimeMillis(); for (UUID uuid : new ArrayList<>(items.keySet())) { + if (shuttingDown.get()) return; Player player = Bukkit.getPlayer(uuid); if (player != null) { plugin.getBukkitScheduler().runTask(plugin, () -> checkOwnedPlayer(player), player); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java index f0a8276fa2..0ee4b1a930 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java @@ -19,7 +19,12 @@ import java.util.List; import java.util.Map.Entry; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; import org.bukkit.Bukkit; @@ -38,6 +43,7 @@ import org.bukkit.potion.PotionEffectType; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; @@ -232,28 +238,64 @@ public void executeConsoleCommands(final String playerName, final ArrayList placeholders) { - if (command != null && !command.isEmpty()) { - OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); - if (p != null) { - command = PlaceholderUtils.replaceJavascriptOnly(p, command); - } - command = PlaceholderUtils.replacePlaceHolder(command, placeholders); - if (p != null) { - command = PlaceholderUtils.replacePlaceHolders(p, command); - } - final String cmd = stripLeadingSlash(command); - - plugin.debug("Executing console command: " + command); - plugin.getBukkitScheduler().executeOrScheduleSync(plugin, new Runnable() { - - @Override - public void run() { - Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), cmd); - } + executeConsoleCommandsAsync(playerName, command, placeholders); + } - }); + /** + * Schedules one console command and completes only after Bukkit has dispatched + * it. Async reward replay uses this completion boundary so it never records a + * durable checkpoint ahead of a queued command. + */ + public CompletionStage executeConsoleCommandsAsync(String playerName, String command, + HashMap placeholders) { + try { + ArrayList templates = command == null || command.isEmpty() + ? new ArrayList<>() : new ArrayList<>(java.util.List.of(command)); + String expanded = command; + OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); + if (expanded != null && !expanded.isEmpty()) { + if (p != null) expanded = PlaceholderUtils.replaceJavascriptOnly(p, expanded); + expanded = PlaceholderUtils.replacePlaceHolder(expanded, placeholders); + if (p != null) expanded = PlaceholderUtils.replacePlaceHolders(p, expanded); + } + ArrayList commands = command == null || command.isEmpty() + ? new ArrayList<>() : new ArrayList<>(java.util.List.of(expanded == null ? "" : expanded)); + return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, + (cmd, ignoredIndex) -> { + plugin.debug("Executing console command: " + cmd); + return runConsoleCommandAsync(stripLeadingSlash(cmd), 0, false, false); + }); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); } + } + /** + * Schedules every console command using the legacy stagger timing and completes + * only after every dispatch has run. This keeps an async reward injector from + * checkpointing a command list while any member is still queued. + */ + @SuppressWarnings("deprecation") + public CompletionStage executeConsoleCommandsAsync(final String playerName, final ArrayList cmds, + final HashMap placeholders, final boolean stagger) { + try { + placeholders.put("player", playerName); + OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); + ArrayList templates = cmds == null ? new ArrayList<>() : new ArrayList<>(cmds); + ArrayList commands = new ArrayList<>(templates); + if (p != null) commands = PlaceholderUtils.replaceJavascriptOnly(p, commands); + commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); + if (p != null) commands = PlaceholderUtils.replacePlaceHolders(p, commands); + + return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, + (command, index) -> { + plugin.debug("Executing console command: " + command); + return runConsoleCommandAsync(stripLeadingSlash(command), index > 0 ? 1 : 0, + stagger && index > 0, stagger || index == 0); + }); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } } public Object getBlockMeta(Block block, String str) { @@ -479,6 +521,42 @@ public void run() { } } + /** + * Schedules one command with a completion boundary. The timeout claims the + * runnable before it can execute, so a task delayed past shutdown cannot run + * after a failed replay has already been retried. + */ + private CompletionStage runConsoleCommandAsync(String command, int delay, boolean hasDelay, + boolean scheduleAsTask) { + CompletableFuture completion = new CompletableFuture<>(); + AtomicBoolean claimed = new AtomicBoolean(); + Runnable dispatch = () -> { + if (!claimed.compareAndSet(false, true)) return; + try { + Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), command); + completion.complete(null); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }; + try { + if (hasDelay && delay > 0) plugin.getBukkitScheduler().runTaskLater(plugin, dispatch, delay); + else if (scheduleAsTask) plugin.getBukkitScheduler().runTask(plugin, dispatch); + else plugin.getBukkitScheduler().executeOrScheduleSync(plugin, dispatch); + } catch (Throwable failure) { + claimed.set(true); + completion.completeExceptionally(failure); + return completion; + } + long timeoutSeconds = 30L + Math.max(0, delay); + CompletableFuture.delayedExecutor(timeoutSeconds, TimeUnit.SECONDS).execute(() -> { + if (claimed.compareAndSet(false, true)) { + completion.completeExceptionally(new TimeoutException("Timed out waiting for scheduled console command")); + } + }); + return completion; + } + private String stripLeadingSlash(String command) { if (command != null && command.startsWith("/")) { return command.substring(1); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/QueuedGeneratedReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/QueuedGeneratedReward.java index f4396aa8b2..ac4443af24 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/QueuedGeneratedReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/QueuedGeneratedReward.java @@ -23,14 +23,25 @@ public final class QueuedGeneratedReward extends Reward { @Override public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { - if (user == null || user.getUUID() == null || !allowedUserUuids.contains(user.getUUID())) { - plugin.getLogger().warning("Blocked generated queued reward " + getRewardName() - + " for a user without a matching persisted queue entry"); - return; - } + if (!isAllowed(user)) return; super.giveReward(user, rewardOptions); } + @Override + public java.util.concurrent.CompletionStage giveRewardAsync(AdvancedCoreUser user, + RewardOptions rewardOptions) { + if (!isAllowed(user)) return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("Generated queued reward is not authorized for this user")); + return super.giveRewardAsync(user, rewardOptions); + } + + private boolean isAllowed(AdvancedCoreUser user) { + if (user != null && user.getUUID() != null && allowedUserUuids.contains(user.getUUID())) return true; + plugin.getLogger().warning("Blocked generated queued reward " + getRewardName() + + " for a user without a matching persisted queue entry"); + return false; + } + @Override public boolean isGeneratedSnapshotCreated() { // A loaded generated snapshot must retain snapshot provenance if execution is diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java index d86d8d2110..5ec245f330 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java @@ -1,13 +1,32 @@ package com.bencodez.advancedcore.api.rewards; import java.io.File; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.text.SimpleDateFormat; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Date; import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.Map.Entry; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import java.util.function.Consumer; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.logging.Level; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -30,6 +49,14 @@ * The Class Reward. */ public class Reward { + private static final ThreadLocal ACTIVE_REPLAY_STATE = new ThreadLocal<>(); + private static final ThreadLocal ACTIVE_REPLAY_KEY = new ThreadLocal<>(); + private static final ThreadLocal ACTIVE_REPLAY_OCCURRENCE_ID = new ThreadLocal<>(); + private static final String REPLAY_SELECTION_PREFIX = "__advancedcore_replay_selection_"; + private static final String REPLAY_COMMAND_PREFIX = "__advancedcore_replay_commands_"; + private static final String REPLAY_NESTED_LIST_PREFIX = "__advancedcore_replay_nested_list_"; + private static final String REPLAY_SINGLE_CHILD_PREFIX = "__advancedcore_replay_single_child_"; + private static final String REPLAY_LEGACY_ACTION_PREFIX = "__advancedcore_replay_legacy_actions_"; @Getter @Setter @@ -245,9 +272,1098 @@ public void giveInjectedRewards(AdvancedCoreUser user, HashMap p } } + /** + * Gives injected rewards in registration order, waiting for each asynchronous + * injection before evaluating the next one. Synchronous injections retain + * their existing behavior, and post-reward injections run after all normal + * injections have completed. + * + * @param user receiving user + * @param placeholders current placeholders + * @return completion stage that completes after all injections have run + */ + public CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, + HashMap placeholders) { + return giveInjectedRewardsAsync(user, placeholders, 0, new ReplayState(null), getRewardName(), + UUID.randomUUID().toString()); + } + + // Compatibility bridge for internal callers/tests that only carry replay path + // metadata. Persisted queue dispatch supplies the explicit occurrence id below. + private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, + HashMap placeholders, int completedStages, ReplayState replayState, String replayKey) { + return giveInjectedRewardsAsync(user, placeholders, completedStages, replayState, replayKey, + UUID.randomUUID().toString()); + } + + /** + * Resumes a persisted asynchronous reward after the supplied number of + * injection stages have completed. Queue recovery uses this checkpoint to + * avoid replaying already-applied non-idempotent injections. + */ + private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, + HashMap placeholders, int completedStages, ReplayState replayState, String replayKey, + String occurrenceId) { + List orderedRewards = orderedInjectedRewards(); + int resumeAfter = replayState.getCompleted(replayKey, completedStages); + if (resumeAfter < 0 || resumeAfter > orderedRewards.size()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Reward replay progress exceeds the injection registry")); + } + String registryFingerprint = injectionRegistryFingerprint(orderedRewards); + if (!replayState.matchesRegistryFingerprint(registryFingerprint)) { + return CompletableFuture.failedFuture(new IncompatibleReplayCheckpointException(replayKey)); + } + AtomicInteger completed = new AtomicInteger(resumeAfter); + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (int index = 0; index < orderedRewards.size(); index++) { + final RewardInject inject = orderedRewards.get(index); + final String injectionKey = replayKey + "/" + index; + if (index < resumeAfter) { + sequence = sequence.thenCompose(ignored -> notifyReplayCheckpointPersisted(inject, user, occurrenceId, + injectionKey)); + continue; + } + sequence = sequence.thenCompose(ignored -> { + // A nested injector can checkpoint a child before this parent injector + // completes. Bind the parent's registry first so that child checkpoint + // cannot later be resumed against a shifted parent ordinal. + replayState.setRegistryFingerprint(replayKey, registryFingerprint); + return invokeInjectionAsync(inject, user, placeholders, replayState, injectionKey, occurrenceId); + }) + .thenCompose(result -> { + if (inject.isAddAsPlaceholder() && result != null) addPlaceholder(inject, result, placeholders); + int checkpoint = completed.incrementAndGet(); + replayState.setCompleted(replayKey, checkpoint); + replayState.setRegistryFingerprint(replayKey, registryFingerprint); + return replayState.persistCheckpointAsync(plugin, placeholders) + .thenCompose(ignored -> notifyReplayCheckpointPersisted(inject, user, occurrenceId, + injectionKey)) + .thenApply(ignored -> result); + }).thenCompose(ignored -> resumeOnServerThread(user)); + } + return sequence.handle((ignored, failure) -> { + if (failure == null) return null; + RewardReplayFailure nestedFailure = findReplayFailure(failure); + if (nestedFailure != null) throw nestedFailure; + replayState.setCompleted(replayKey, completed.get()); + throw new RewardReplayFailure(replayState, placeholders, failure); + }); + } + + private CompletionStage notifyReplayCheckpointPersisted(RewardInject inject, AdvancedCoreUser user, + String occurrenceId, + String injectionKey) { + try { + CompletionStage notification = inject.onReplayCheckpointPersisted(this, user, occurrenceId, + injectionKey); + return notification == null ? CompletableFuture.failedFuture(new IllegalStateException( + "Reward injection returned a null replay-checkpoint result: " + inject.getPath())) : notification; + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + + private List orderedInjectedRewards() { + List postRewards = new ArrayList<>(); + List orderedRewards = new ArrayList<>(); + for (RewardInject inject : plugin.getRewardHandler().getInjectedRewards()) { + if (inject.isPostReward()) postRewards.add(inject); + else orderedRewards.add(inject); + } + orderedRewards.addAll(postRewards); + return orderedRewards; + } + + /** + * A persisted checkpoint is valid only for this exact ordered injector + * registry. Paths, implementation classes, priorities, and post-reward + * placement are all included because each affects which side effect an + * ordinal identifies. + */ + private static String injectionRegistryFingerprint(List orderedRewards) { + StringBuilder registry = new StringBuilder(); + for (RewardInject inject : orderedRewards) { + String path = inject.getPath() == null ? "" : inject.getPath(); + registry.append(inject.getClass().getName()).append('\t').append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(path.getBytes(StandardCharsets.UTF_8))).append('\t') + .append(inject.getPriority()).append('\t').append(inject.isPostReward()).append('\n'); + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(registry.toString().getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte value : digest) hex.append(String.format("%02x", value & 0xff)); + return hex.toString(); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable for reward replay checkpoints", failure); + } + } + + // Kept as a narrow compatibility bridge for internal callers/tests that only + // have a single reward checkpoint. + private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, + HashMap placeholders, int completedStages) { + return giveInjectedRewardsAsync(user, placeholders, completedStages, new ReplayState(null), getRewardName(), + UUID.randomUUID().toString()); + } + + /** Details the last durably safe replay checkpoint when an async chain fails. */ + public static final class RewardReplayFailure extends RuntimeException { + private static final long serialVersionUID = 1L; + private final ReplayState replayState; + @Getter private final int completedInjectionCount; + @Getter private final HashMap replayPlaceholders; + + private RewardReplayFailure(ReplayState replayState, HashMap replayPlaceholders, Throwable cause) { + super("Asynchronous reward replay failed after a completed asynchronous reward stage", + cause); + this.replayState = replayState; + this.completedInjectionCount = replayState.highestCompletedCount(); + HashMap placeholders = new HashMap<>(replayPlaceholders); + replayState.mergeReplayMetadataInto(placeholders); + this.replayPlaceholders = placeholders; + } + + public Map getReplayProgress() { return replayState.copyProgress(); } + public Map getReplayRegistryFingerprints() { return replayState.copyRegistryFingerprints(); } + } + + /** Prevents an ordinal checkpoint from targeting a changed injector registry. */ + private static final class IncompatibleReplayCheckpointException extends IllegalStateException { + private static final long serialVersionUID = 1L; + private IncompatibleReplayCheckpointException(String replayKey) { + super("Cannot safely resume asynchronous reward replay '" + replayKey + + "' because its injector registry changed or its legacy checkpoint has no registry fingerprint"); + } + } + + /** Internal completion signal for a persisted offline occurrence that was re-queued. */ + private static final class OfflineReplayDeferredException extends IllegalStateException { + private static final long serialVersionUID = 1L; + private OfflineReplayDeferredException() { + super("Persisted offline reward remains deferred"); + } + } + + /** Whether a completion failure means the offline occurrence was durably re-queued. */ + public static boolean isOfflineReplayDeferred(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof OfflineReplayDeferredException) return true; + } + return false; + } + + private static RewardReplayFailure findReplayFailure(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof RewardReplayFailure) return (RewardReplayFailure) current; + } + return null; + } + + /** Captures the active nested replay state for deferred child dispatch. */ + public static ReplayState currentReplayState() { return ACTIVE_REPLAY_STATE.get(); } + + /** Captures the active parent execution path before deferred work runs. */ + public static String currentReplayKey() { return ACTIVE_REPLAY_KEY.get(); } + + /** Returns the unique logical reward occurrence currently invoking an injector. */ + public static String currentReplayOccurrenceId() { return ACTIVE_REPLAY_OCCURRENCE_ID.get(); } + + /** + * Returns a stable side-effect identity scoped to the current replay path. + * Repeated nested occurrences share a top-level occurrence id but have + * distinct replay keys, so both parts are required for deduplication. + */ + public static String currentReplaySideEffectOccurrenceId() { + return replaySideEffectOccurrenceId(currentReplayOccurrenceId(), currentReplayKey()); + } + + /** Builds the stable side-effect identity for an explicitly captured replay path. */ + public static String replaySideEffectOccurrenceId(String occurrenceId, String replayKey) { + if (occurrenceId == null || occurrenceId.isEmpty() || replayKey == null || replayKey.isEmpty()) { + return occurrenceId; + } + return occurrenceId + ":" + digest(replayKey); + } + + /** Obtains one shared replay state for a group of nested dispatches. */ + public static ReplayState replayStateFor(RewardOptions options) { + ReplayState replayState = options.getAsyncReplayState(); + if (replayState == null) { + replayState = ACTIVE_REPLAY_STATE.get(); + if (replayState == null) { + // A caller may reuse its RewardOptions for independent recipients. Keep + // fresh top-level execution state local to this dispatch so completed + // stages cannot leak into the next send. + replayState = new ReplayState(options.getAsyncReplayProgress(), + options.getAsyncReplayRegistryFingerprints(), options.isLegacyAsyncReplayCheckpoint()); + } else { + // Deferred/nested options must retain their explicitly inherited parent + // state after the current thread-local scope ends. + options.setAsyncReplayState(replayState); + } + } + if (options.getAsyncReplayCheckpointConsumer() != null) { + replayState.setCheckpointConsumer(options.getAsyncReplayCheckpointConsumer()); + } + return replayState; + } + + /** Returns whether these options participate in durable queued replay. */ + public static boolean isDurableReplay(RewardOptions options) { + if (options == null) return false; + if (options.getAsyncReplayCheckpointConsumer() != null) return true; + ReplayState replayState = options.getAsyncReplayState(); + return replayState != null && replayState.hasCheckpointConsumer(); + } + + /** + * Copies the durable portion of an in-flight replay into options before the + * reward is deferred. Pause/vanish handling happens before the async + * injector creates its next checkpoint, so the queue must carry the state + * already accumulated by a nested replay rather than starting a new + * occurrence with placeholders only. + */ + public static void preserveReplayState(RewardOptions options) { + if (options == null || options.getAsyncReplayState() == null) return; + options.getAsyncReplayState().copyTo(options); + } + + /** + * Records a nondeterministic nested-reward decision in the placeholder + * snapshot carried by {@link RewardReplayFailure}. A retry consequently + * executes the same selected branch instead of rolling a new outcome after a + * child has already performed a side effect. + */ + public static String replaySelection(HashMap placeholders, Supplier selector) { + return replaySelection(placeholders, null, selector); + } + + /** Records an independent nondeterministic decision within the active injection. */ + public static String replaySelection(HashMap placeholders, String lane, Supplier selector) { + String activeKey = ACTIVE_REPLAY_KEY.get(); + String scopedKey = activeKey == null ? "root" : activeKey; + if (lane != null && !lane.isEmpty()) scopedKey += "\u0000" + lane; + String storageKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding() + .encodeToString(scopedKey.getBytes(StandardCharsets.UTF_8)); + ReplayState replayState = ACTIVE_REPLAY_STATE.get(); + String stored = replayMetadata(placeholders, replayState, storageKey); + if (stored != null) { + if (replayState != null) replayState.recordReplayMetadata(storageKey, stored); + return stored.isEmpty() ? null : new String(Base64.getUrlDecoder().decode(stored), StandardCharsets.UTF_8); + } + String selected = selector.get(); + String encoded = selected == null ? "" : Base64.getUrlEncoder().withoutPadding() + .encodeToString(selected.getBytes(StandardCharsets.UTF_8)); + recordReplayMetadata(placeholders, replayState, storageKey, encoded); + return selected; + } + + /** Returns whether the active injection already persisted a selected branch. */ + public static boolean hasReplaySelection(HashMap placeholders) { + String activeKey = ACTIVE_REPLAY_KEY.get(); + if (activeKey == null) return false; + String storageKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding() + .encodeToString(activeKey.getBytes(StandardCharsets.UTF_8)); + return replayMetadata(placeholders, ACTIVE_REPLAY_STATE.get(), storageKey) != null; + } + + /** Durably records replay metadata before dispatching its selected side effect. */ + public static CompletionStage persistReplayMetadataAsync(AdvancedCorePlugin plugin, + HashMap placeholders) { + ReplayState replayState = ACTIVE_REPLAY_STATE.get(); + return replayState == null ? CompletableFuture.completedFuture(null) + : replayState.persistCheckpointAsync(plugin, placeholders); + } + + /** Executes a command list sequentially and durably records each successful item. */ + public static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List commands, + Function> dispatch) { + return replayCommandSequence(plugin, placeholders, lane, commands, + (command, ignoredIndex) -> dispatch.apply(command)); + } + + /** Executes an indexed command list sequentially and durably records each successful item. */ + public static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List commands, + BiFunction> dispatch) { + return replayCommandSequence(plugin, placeholders, lane, commands, commands, currentReplayState(), currentReplayKey(), dispatch); + } + + /** Executes expanded commands while identifying the sequence by stable templates. */ + public static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List commandTemplates, + List expandedCommands, BiFunction> dispatch) { + return replayCommandSequence(plugin, placeholders, lane, commandTemplates, expandedCommands, + currentReplayState(), currentReplayKey(), dispatch); + } + + static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List commandTemplates, + List expandedCommands, + ReplayState replayState, String activeKey, + BiFunction> dispatch) { + if (replayState == null || activeKey == null) { + if (commandTemplates == null || commandTemplates.isEmpty()) return CompletableFuture.completedFuture(null); + if (expandedCommands == null || expandedCommands.size() != commandTemplates.size()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Command templates and expansions differ in size")); + } + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (int index = 0; index < expandedCommands.size(); index++) { + String command = expandedCommands.get(index); + int commandIndex = index; + sequence = sequence.thenCompose(ignored -> dispatch.apply(command, commandIndex)); + } + return sequence; + } + String storageKey = replaySequenceKey(REPLAY_COMMAND_PREFIX, activeKey, + lane == null ? "commands" : lane); + String snapshotKey = storageKey + "_snapshot"; + List commands; + String storedSnapshot = replayMetadata(placeholders, replayState, snapshotKey); + boolean createdSnapshot = storedSnapshot == null; + if (storedSnapshot == null) { + List templates = commandTemplates == null ? List.of() : commandTemplates; + List expansions = expandedCommands == null ? List.of() : expandedCommands; + if (expansions.size() != templates.size()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Command templates and expansions differ in size")); + } + commands = new ArrayList<>(expansions); + String encodedSnapshot = encodeCommandSnapshot(commands); + recordReplayMetadata(placeholders, replayState, snapshotKey, encodedSnapshot); + } else { + replayState.recordReplayMetadata(snapshotKey, storedSnapshot); + try { + commands = decodeCommandSnapshot(storedSnapshot); + } catch (IllegalArgumentException failure) { + return CompletableFuture.failedFuture(new IllegalStateException("Malformed command replay snapshot", failure)); + } + } + String storedProgress = replayMetadata(placeholders, replayState, storageKey); + int completed; + try { + completed = storedProgress == null ? 0 : Integer.parseInt(storedProgress); + } catch (NumberFormatException failure) { + return CompletableFuture.failedFuture( + new IllegalStateException("Malformed command replay progress", failure)); + } + if (storedProgress != null) replayState.recordReplayMetadata(storageKey, storedProgress); + if (completed < 0 || completed > commands.size()) { + return CompletableFuture.failedFuture(new IllegalStateException("Command replay progress exceeds snapshot")); + } + // Persist the concrete expansion before issuing its first side effect. A + // restart during an indeterminate dispatch must reuse this exact payload, + // even though no command-progress marker exists yet. + CompletionStage sequence = createdSnapshot + ? replayState.persistCheckpointAsync(plugin, placeholders) + : CompletableFuture.completedFuture(null); + for (int index = completed; index < commands.size(); index++) { + String command = commands.get(index); + int completedCount = index + 1; + int commandIndex = index; + sequence = sequence.thenCompose(ignored -> dispatch.apply(command, commandIndex)).thenCompose(ignored -> { + recordReplayMetadata(placeholders, replayState, storageKey, String.valueOf(completedCount)); + return replayState == null ? CompletableFuture.completedFuture(null) + : replayState.persistCheckpointAsync(plugin, placeholders); + }); + } + return sequence; + } + + /** Freezes one nested reward list before any child side effect is dispatched. */ + public static CompletionStage> replayNestedRewardSnapshot(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List configuredRewards, + ReplayState replayState, String activeKey) { + if (replayState == null || activeKey == null) { + return CompletableFuture.completedFuture(configuredRewards == null ? List.of() + : new ArrayList<>(configuredRewards)); + } + String snapshotKey = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, + lane == null ? "nested" : lane) + "_snapshot"; + String storedSnapshot = replayMetadata(placeholders, replayState, snapshotKey); + if (storedSnapshot != null) { + replayState.recordReplayMetadata(snapshotKey, storedSnapshot); + try { + return CompletableFuture.completedFuture(decodeCommandSnapshot(storedSnapshot)); + } catch (IllegalArgumentException failure) { + return CompletableFuture.failedFuture( + new IllegalStateException("Malformed nested reward replay snapshot", failure)); + } + } + List snapshot = configuredRewards == null ? List.of() : new ArrayList<>(configuredRewards); + String encodedSnapshot = encodeCommandSnapshot(snapshot); + recordReplayMetadata(placeholders, replayState, snapshotKey, encodedSnapshot); + return replayState.persistCheckpointAsync(plugin, placeholders).thenApply(ignored -> snapshot); + } + + /** + * Executes a frozen nested reward list sequentially and checkpoints each + * completed child. The parent cursor is consulted before dispatch so a child + * that disappeared from the reward registry after completing is never + * resolved again during replay. + */ + public static CompletionStage replayNestedRewardSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List configuredRewards, + ReplayState replayState, String activeKey, + BiFunction> dispatch) { + if (replayState == null || activeKey == null) { + CompletionStage sequence = CompletableFuture.completedFuture(null); + List rewards = configuredRewards == null ? List.of() : configuredRewards; + for (int index = 0; index < rewards.size(); index++) { + String rewardName = rewards.get(index); + int rewardIndex = index; + sequence = sequence.thenCompose(ignored -> dispatch.apply(rewardName, rewardIndex)); + } + return sequence; + } + String stableLane = lane == null ? "nested" : lane; + String storageKey = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, stableLane); + return replayNestedRewardSnapshot(plugin, placeholders, stableLane, configuredRewards, replayState, activeKey) + .thenCompose(rewards -> { + String storedProgress = replayMetadata(placeholders, replayState, storageKey); + int completed; + try { + completed = storedProgress == null ? 0 : Integer.parseInt(storedProgress); + } catch (NumberFormatException failure) { + return CompletableFuture.failedFuture( + new IllegalStateException("Malformed nested reward replay progress", failure)); + } + if (storedProgress != null) replayState.recordReplayMetadata(storageKey, storedProgress); + if (completed < 0 || completed > rewards.size()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Nested reward replay progress exceeds snapshot")); + } + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (int index = completed; index < rewards.size(); index++) { + String rewardName = rewards.get(index); + int rewardIndex = index; + int completedCount = index + 1; + sequence = sequence.thenCompose(ignored -> dispatch.apply(rewardName, rewardIndex)) + .thenCompose(ignored -> { + String progress = String.valueOf(completedCount); + recordReplayMetadata(placeholders, replayState, storageKey, progress); + return replayState.persistCheckpointAsync(plugin, placeholders); + }); + } + return sequence; + }); + } + + /** + * Dispatches one nested child and persists its completion before the parent + * injector can complete. A retry consults this marker before resolving the + * child, so a completed child may safely disappear from configuration. + */ + public static CompletionStage replaySingleNestedReward(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, ReplayState replayState, String activeKey, + Supplier> dispatch) { + if (replayState == null || activeKey == null) { + try { + CompletionStage result = dispatch.get(); + return result == null ? CompletableFuture.failedFuture( + new IllegalStateException("Nested reward dispatch returned no completion stage")) : result; + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + String storageKey = replaySequenceKey(REPLAY_SINGLE_CHILD_PREFIX, activeKey, + lane == null ? "selected" : lane); + String stored = replayMetadata(placeholders, replayState, storageKey); + if (stored != null) { + if (!"1".equals(stored)) return CompletableFuture.failedFuture( + new IllegalStateException("Malformed nested reward completion marker")); + replayState.recordReplayMetadata(storageKey, stored); + return CompletableFuture.completedFuture(null); + } + CompletionStage result; + try { + result = dispatch.get(); + if (result == null) return CompletableFuture.failedFuture( + new IllegalStateException("Nested reward dispatch returned no completion stage")); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + return result.thenCompose(ignored -> { + recordReplayMetadata(placeholders, replayState, storageKey, "1"); + return replayState.persistCheckpointAsync(plugin, placeholders); + }); + } + + /** Returns whether the active single nested child is already durably complete. */ + public static boolean hasCompletedSingleNestedReward(HashMap placeholders, String lane) { + ReplayState replayState = currentReplayState(); + String activeKey = currentReplayKey(); + if (replayState == null || activeKey == null) return false; + String storageKey = replaySequenceKey(REPLAY_SINGLE_CHILD_PREFIX, activeKey, + lane == null ? "selected" : lane); + String stored = replayMetadata(placeholders, replayState, storageKey); + if (!"1".equals(stored)) return false; + replayState.recordReplayMetadata(storageKey, stored); + return true; + } + + private static String replaySequenceKey(String prefix, String activeKey, String lane) { + return prefix + digest(activeKey.length() + ":" + activeKey + lane.length() + ":" + lane); + } + + /** Returns whether the active replay already froze this logical command lane. */ + public static boolean hasReplayCommandSnapshot(HashMap placeholders, String lane) { + ReplayState replayState = currentReplayState(); + String activeKey = currentReplayKey(); + if (replayState == null || activeKey == null) return false; + String storageKey = replaySequenceKey(REPLAY_COMMAND_PREFIX, activeKey, + lane == null ? "commands" : lane); + return replayMetadata(placeholders, replayState, storageKey + "_snapshot") != null; + } + + /** Returns whether a frozen command lane still has an undispatched command. */ + public static boolean hasPendingReplayCommandWork(HashMap placeholders, String lane) { + ReplayState replayState = currentReplayState(); + String activeKey = currentReplayKey(); + if (replayState == null || activeKey == null) return false; + String storageKey = replaySequenceKey(REPLAY_COMMAND_PREFIX, activeKey, + lane == null ? "commands" : lane); + String storedSnapshot = replayMetadata(placeholders, replayState, storageKey + "_snapshot"); + if (storedSnapshot == null) return false; + List commands; + try { + commands = decodeCommandSnapshot(storedSnapshot); + } catch (IllegalArgumentException failure) { + throw new IllegalStateException("Malformed command replay snapshot", failure); + } + String storedProgress = replayMetadata(placeholders, replayState, storageKey); + int completed; + try { + completed = storedProgress == null ? 0 : Integer.parseInt(storedProgress); + } catch (NumberFormatException failure) { + throw new IllegalStateException("Malformed command replay progress", failure); + } + if (completed < 0 || completed > commands.size()) { + throw new IllegalStateException("Command replay progress exceeds snapshot"); + } + return completed < commands.size(); + } + + /** Returns whether the active replay already froze this nested reward lane. */ + public static boolean hasReplayNestedRewardSnapshot(HashMap placeholders, String lane) { + return hasReplayNestedRewardSnapshot(placeholders, lane, currentReplayState(), currentReplayKey()); + } + + /** Returns whether the supplied replay already froze this nested reward lane. */ + public static boolean hasReplayNestedRewardSnapshot(HashMap placeholders, String lane, + ReplayState replayState, String activeKey) { + if (replayState == null || activeKey == null) return false; + String storageKey = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, + lane == null ? "nested" : lane); + return replayMetadata(placeholders, replayState, storageKey + "_snapshot") != null; + } + + /** Returns whether every child in a frozen nested-reward lane is complete. */ + public static boolean hasCompletedNestedRewardSequence(HashMap placeholders, String lane) { + ReplayState replayState = currentReplayState(); + String activeKey = currentReplayKey(); + if (replayState == null || activeKey == null) return false; + String storageKey = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, + lane == null ? "nested" : lane); + String storedSnapshot = replayMetadata(placeholders, replayState, storageKey + "_snapshot"); + if (storedSnapshot == null) return false; + List rewards; + try { + rewards = decodeCommandSnapshot(storedSnapshot); + } catch (IllegalArgumentException failure) { + throw new IllegalStateException("Malformed nested reward replay snapshot", failure); + } + String storedProgress = replayMetadata(placeholders, replayState, storageKey); + int completed; + try { + completed = storedProgress == null ? 0 : Integer.parseInt(storedProgress); + } catch (NumberFormatException failure) { + throw new IllegalStateException("Malformed nested reward replay progress", failure); + } + if (completed < 0 || completed > rewards.size()) { + throw new IllegalStateException("Nested reward replay progress exceeds snapshot"); + } + return completed == rewards.size(); + } + + /** Reads reserved replay metadata without accepting stale caller values for a fresh execution. */ + public static String replayMetadata(HashMap placeholders, ReplayState replayState, String key) { + String value = placeholders == null || (replayState != null && !replayState.acceptsPersistedMetadata()) + ? null : placeholders.get(key); + return value == null && replayState != null ? replayState.replayMetadata(key) : value; + } + + /** Records reserved replay metadata in the execution-local state and only durable caller placeholders. */ + public static void recordReplayMetadata(HashMap placeholders, ReplayState replayState, + String key, String value) { + if (replayState != null) replayState.recordReplayMetadata(key, value); + if (placeholders != null && (replayState == null || replayState.hasCheckpointConsumer())) { + placeholders.put(key, value); + } + } + + static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List commands, + ReplayState replayState, String activeKey, + BiFunction> dispatch) { + return replayCommandSequence(plugin, placeholders, lane, commands, commands, replayState, activeKey, dispatch); + } + + private static String encodeCommandSnapshot(List commands) { + StringBuilder encoded = new StringBuilder("v1:"); + for (String command : commands) { + if (encoded.length() > 3) encoded.append('.'); + if (command == null) encoded.append('~'); + else if (command.isEmpty()) encoded.append('='); + else encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(command.getBytes(StandardCharsets.UTF_8))); + } + return encoded.toString(); + } + + private static List decodeCommandSnapshot(String encoded, int expectedSize) { + List commands = decodeCommandSnapshot(encoded); + if (commands.size() != expectedSize) throw new IllegalArgumentException("Command snapshot size changed"); + return commands; + } + + private static List decodeCommandSnapshot(String encoded) { + if (!encoded.startsWith("v1:")) throw new IllegalArgumentException("Unknown command snapshot version"); + String body = encoded.substring(3); + ArrayList commands = new ArrayList<>(); + if (!body.isEmpty()) { + for (String item : body.split("\\.", -1)) { + if (item.equals("~")) { + commands.add(null); + continue; + } + if (item.equals("=")) { + commands.add(""); + continue; + } + try { + commands.add(new String(Base64.getUrlDecoder().decode(item), StandardCharsets.UTF_8)); + } catch (IllegalArgumentException failure) { + throw new IllegalArgumentException("Invalid command snapshot encoding", failure); + } + } + } + return commands; + } + + private static String digest(String value) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte item : bytes) hex.append(String.format("%02x", item & 0xff)); + return hex.toString(); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable for reward replay checkpoints", failure); + } + } + + /** Copies only durable replay markers; ordinary reward placeholders stay isolated. */ + public static void mergeReplayMetadata(HashMap target, Map source) { + if (target == null || source == null) return; + for (Entry entry : source.entrySet()) { + if (isReplayMetadataKey(entry.getKey())) target.put(entry.getKey(), entry.getValue()); + } + } + + private static boolean isReplayMetadataKey(String key) { + return key != null && (key.startsWith(REPLAY_SELECTION_PREFIX) || key.startsWith(REPLAY_COMMAND_PREFIX) + || key.startsWith(REPLAY_NESTED_LIST_PREFIX) || key.startsWith(REPLAY_SINGLE_CHILD_PREFIX) + || key.startsWith(REPLAY_LEGACY_ACTION_PREFIX)); + } + + /** Stable metadata key for the individual legacy actions produced by one injector. */ + public static String legacyActionReplayKey(String injectionKey) { + String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString( + (injectionKey == null ? "" : injectionKey).getBytes(StandardCharsets.UTF_8)); + return REPLAY_LEGACY_ACTION_PREFIX + encoded; + } + + /** Stable fingerprint for an already-resolved legacy reward action payload. */ + public static String legacyActionFingerprint(String descriptor) { + return digest(descriptor == null ? "" : descriptor); + } + + /** Reads the number of legacy actions that have a durable per-injection checkpoint. */ + public static int completedLegacyActions(ReplayState replayState, HashMap placeholders, + String checkpointKey) { + String value = placeholders == null ? null : placeholders.get(checkpointKey); + if (value == null && replayState != null) value = replayState.replayMetadata(checkpointKey); + try { + return Math.max(0, value == null ? 0 : Integer.parseInt(value)); + } catch (NumberFormatException ignored) { + return 0; + } + } + + /** Attaches a captured replay state to an option object for a deferred child. */ + public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState) { + if (replayState != null) options.setAsyncReplayState(replayState); + String occurrenceId = ACTIVE_REPLAY_OCCURRENCE_ID.get(); + if (occurrenceId != null) options.setAsyncReplayOccurrenceId(occurrenceId); + return options; + } + + /** + * Associates a deferred nested reward with a deterministic child occurrence. + * A list may intentionally contain the same reward more than once, so its + * replay checkpoint must never be shared merely because its name is equal. + */ + public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState, + String childOccurrence) { + return withReplayState(options, replayState, ACTIVE_REPLAY_KEY.get(), childOccurrence); + } + + /** + * Associates deferred nested work with the parent path captured while the + * injector was invoked. Later completion callbacks do not retain ThreadLocal + * context, so they must use this explicit form. + */ + public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState, + String parentKey, String childOccurrence) { + return withReplayState(options, replayState, parentKey, childOccurrence, ACTIVE_REPLAY_OCCURRENCE_ID.get()); + } + + /** + * Explicit deferred-child form. Completion callbacks must carry both the + * replay path and its logical occurrence because ThreadLocal context is not + * retained after an asynchronous parent injector returns. + */ + public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState, + String parentKey, String childOccurrence, String occurrenceId) { + if (replayState != null) options.setAsyncReplayState(replayState); + if (occurrenceId != null) { + String effectiveOccurrence = occurrenceId; + // Fresh nested children can each be deferred as independent offline queue + // entries. Give those siblings stable distinct identities so adding the next + // child cannot replace the previous one. A durable parent keeps its shared + // occurrence because its checkpoint owns and resumes the complete sequence. + if (childOccurrence != null && (replayState == null || !replayState.hasCheckpointConsumer())) { + String childKey = parentKey == null ? childOccurrence : parentKey + "/" + childOccurrence; + effectiveOccurrence = replaySideEffectOccurrenceId(occurrenceId, childKey); + } + options.setAsyncReplayOccurrenceId(effectiveOccurrence); + } + if (parentKey != null && childOccurrence != null) { + options.setAsyncReplayKey(parentKey + "/" + childOccurrence); + } + return options; + } + + /** Internal shared state passed through nested async reward dispatch. */ + public static final class ReplayState { + private final HashMap completed = new HashMap<>(); + private final HashMap registryFingerprints = new HashMap<>(); + private final HashMap replayMetadata = new HashMap<>(); + private final boolean legacyCheckpoint; + private final boolean restoredCheckpoint; + private Consumer checkpointConsumer; + private ReplayState(Map initial) { this(initial, null, false); } + private ReplayState(Map initial, Map initialFingerprints, + boolean legacyCheckpoint) { + if (initial != null) completed.putAll(initial); + if (initialFingerprints != null) registryFingerprints.putAll(initialFingerprints); + this.legacyCheckpoint = legacyCheckpoint; + this.restoredCheckpoint = legacyCheckpoint || (initial != null && !initial.isEmpty()) + || (initialFingerprints != null && !initialFingerprints.isEmpty()); + } + private synchronized int getCompleted(String rewardName, int fallback) { + return completed.getOrDefault(rewardName, fallback); + } + private synchronized void setCompleted(String rewardName, int count) { completed.put(rewardName, count); } + private synchronized Map copyProgress() { return new HashMap<>(completed); } + private synchronized void setRegistryFingerprint(String rewardName, String fingerprint) { + registryFingerprints.put(rewardName, fingerprint); + } + private synchronized Map copyRegistryFingerprints() { + return new HashMap<>(registryFingerprints); + } + /** Records a reserved marker produced by a nested replay-aware injector. */ + public synchronized void recordReplayMetadata(String key, String value) { + if (isReplayMetadataKey(key)) replayMetadata.put(key, value); + } + public synchronized void mergeReplayMetadataInto(HashMap target) { + Reward.mergeReplayMetadata(target, replayMetadata); + } + public synchronized String replayMetadata(String key) { return replayMetadata.get(key); } + private synchronized boolean hasPersistedCheckpoint() { + return legacyCheckpoint || !completed.isEmpty() || !registryFingerprints.isEmpty(); + } + private synchronized void copyTo(RewardOptions options) { + options.setCompletedAsyncInjections(Math.max(options.getCompletedAsyncInjections(), highestCompletedCount())); + options.setAsyncReplayProgress(copyProgress()); + options.setAsyncReplayRegistryFingerprints(copyRegistryFingerprints()); + options.setLegacyAsyncReplayCheckpoint(legacyCheckpoint); + mergeReplayMetadataInto(options.getPlaceholders()); + } + private synchronized boolean matchesRegistryFingerprint(String currentFingerprint) { + if (legacyCheckpoint) return false; + for (Entry entry : completed.entrySet()) { + if (entry.getValue() != null && entry.getValue() > 0 + && !currentFingerprint.equals(registryFingerprints.get(entry.getKey()))) return false; + } + for (String fingerprint : registryFingerprints.values()) { + if (!currentFingerprint.equals(fingerprint)) return false; + } + return true; + } + private synchronized int highestCompletedCount() { + int highest = 0; + for (int count : completed.values()) highest = Math.max(highest, count); + return highest; + } + private synchronized void setCheckpointConsumer(Consumer consumer) { + checkpointConsumer = consumer; + } + private synchronized boolean hasCheckpointConsumer() { + return checkpointConsumer != null; + } + private synchronized boolean acceptsPersistedMetadata() { + return restoredCheckpoint || checkpointConsumer != null; + } + public CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, + HashMap placeholders) { + return persistCheckpointAsync(plugin, placeholders, 30, TimeUnit.SECONDS); + } + + private CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, + HashMap placeholders, long timeout, TimeUnit timeoutUnit) { + Consumer consumer; + synchronized (this) { consumer = checkpointConsumer; } + if (consumer == null) return CompletableFuture.completedFuture(null); + ReplayCheckpoint checkpoint = new ReplayCheckpoint(copyProgress(), copyRegistryFingerprints(), placeholders); + CompletableFuture persisted = new CompletableFuture<>(); + AtomicBoolean claimed = new AtomicBoolean(); + try { + plugin.getTimer().execute(() -> { + if (!claimed.compareAndSet(false, true)) return; + try { + consumer.accept(checkpoint); + persisted.complete(null); + } catch (Throwable failure) { + persisted.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + if (claimed.compareAndSet(false, true)) persisted.completeExceptionally(failure); + } + // shutdownNow() may remove a task that was accepted just before runtime + // teardown. Bound that indeterminate state so the replay fails closed + // instead of waiting forever without advancing its checkpoint. The same + // claim prevents a backlogged task from persisting after timeout recovery. + CompletableFuture.delayedExecutor(timeout, timeoutUnit).execute(() -> { + if (claimed.compareAndSet(false, true)) { + persisted.completeExceptionally(new TimeoutException("Timed out waiting to persist reward checkpoint")); + } + }); + return persisted; + } + + } + + /** Immutable durable replay data emitted after every completed injection. */ + public static final class ReplayCheckpoint { + @Getter private final Map replayProgress; + @Getter private final Map replayRegistryFingerprints; + @Getter private final HashMap placeholders; + private ReplayCheckpoint(Map replayProgress, HashMap placeholders) { + this(replayProgress, new HashMap<>(), placeholders); + } + private ReplayCheckpoint(Map replayProgress, Map replayRegistryFingerprints, + HashMap placeholders) { + this.replayProgress = new HashMap<>(replayProgress); + this.replayRegistryFingerprints = new HashMap<>(replayRegistryFingerprints); + this.placeholders = new HashMap<>(placeholders); + } + } + + private CompletionStage invokeInjectionAsync(RewardInject inject, AdvancedCoreUser user, + HashMap placeholders, ReplayState replayState, String injectionKey, String occurrenceId) { + try { + if (!plugin.isEnabled()) return CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before reward injection completed")); + Supplier> request = () -> requestOnServerThread(user, + () -> requestInjectionAsync(inject, user, placeholders, replayState, injectionKey, occurrenceId)); + CompletionStage result = inject.isSynchronize() && inject.supportsAsyncSynchronization() + ? inject.runSynchronizedAsync(request) + : request.get(); + if (result == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Reward injection returned a null asynchronous result: " + inject.getPath())); + } + return result; + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } + + private CompletionStage requestOnServerThread(AdvancedCoreUser user, + Supplier> request) { + return requestOnServerThread(plugin, user, request, getServerThreadDispatchTimeoutMillis()); + } + + /** Continues nested reward setup on the owning Bukkit scheduler after async persistence. */ + public static CompletionStage continueOnServerThread(AdvancedCorePlugin plugin, AdvancedCoreUser user, + Supplier> request) { + return requestOnServerThread(plugin, user, request, TimeUnit.SECONDS.toMillis(30)); + } + + private static CompletionStage requestOnServerThread(AdvancedCorePlugin plugin, AdvancedCoreUser user, + Supplier> request, long timeoutMillis) { + CompletableFuture> handoff = new CompletableFuture<>(); + Runnable invocation = () -> { + if (!plugin.isEnabled()) { + handoff.complete(CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before reward injection completed"))); + return; + } + CompletableFuture result = new CompletableFuture<>(); + // Claim the handoff before invoking user code. If the timeout won, this + // queued task must not produce late reward side effects. + if (!handoff.complete(result)) return; + try { + CompletionStage stage = request.get(); + if (stage == null) { + result.completeExceptionally( + new IllegalStateException("Reward injection returned a null asynchronous result")); + return; + } + stage.whenComplete((value, failure) -> { + if (failure == null) result.complete(value); + else result.completeExceptionally(failure); + }); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + }; + Runnable resolvePlayer = () -> { + if (!plugin.isEnabled()) { + handoff.complete(CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before reward injection completed"))); + return; + } + try { + Player player = user.getPlayer(); + if (player != null) plugin.getBukkitScheduler().executeOrScheduleSync(plugin, invocation, player); + else invocation.run(); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + }; + try { + plugin.getBukkitScheduler().executeOrScheduleSync(plugin, resolvePlayer); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + return handoff.orTimeout(timeoutMillis, TimeUnit.MILLISECONDS) + .thenCompose(stage -> stage); + } + + private CompletionStage resumeOnServerThread(AdvancedCoreUser user) { + return requestOnServerThread(user, () -> CompletableFuture.completedFuture(null)); + } + + /** Maximum time to wait when a scheduler drops a task during plugin shutdown. */ + protected long getServerThreadDispatchTimeoutMillis() { + return TimeUnit.SECONDS.toMillis(30); + } + + private CompletionStage requestInjectionAsync(RewardInject inject, AdvancedCoreUser user, + HashMap placeholders, ReplayState replayState, String injectionKey, String occurrenceId) { + ReplayState previous = ACTIVE_REPLAY_STATE.get(); + String previousKey = ACTIVE_REPLAY_KEY.get(); + String previousOccurrenceId = ACTIVE_REPLAY_OCCURRENCE_ID.get(); + ACTIVE_REPLAY_STATE.set(replayState); + ACTIVE_REPLAY_KEY.set(injectionKey); + if (occurrenceId == null) ACTIVE_REPLAY_OCCURRENCE_ID.remove(); + else ACTIVE_REPLAY_OCCURRENCE_ID.set(occurrenceId); + AdvancedCoreUser.AsyncActionCollection actionCollection = user.beginAsyncActionCollection(replayState, placeholders, + injectionKey); + CompletionStage result = CompletableFuture.failedFuture( + new IllegalStateException("Reward injection did not produce a result")); + try { + if (replayState.hasCheckpointConsumer() + && inject.isPlayerRequiredFor(getConfig().getConfigData(), placeholders) + && user.getPlayer() == null) { + result = CompletableFuture.failedFuture( + new IllegalStateException("Player became unavailable before " + inject.getPath() + " delivery")); + } else if (inject.supportsAsyncRequest()) { + result = inject.onRewardRequestAsync(this, user, getConfig().getConfigData(), placeholders); + } else { + try { + result = CompletableFuture.completedFuture( + inject.onRewardRequest(this, user, getConfig().getConfigData(), placeholders)); + } catch (Exception failure) { + // Preserve the legacy per-injection isolation contract while allowing + // persisted replays to retain their queue entry when a player-bound + // legacy injection fails after an earlier asynchronous stage. + failure.printStackTrace(); + result = replayState.hasCheckpointConsumer() + ? CompletableFuture.failedFuture(failure) + : CompletableFuture.completedFuture(null); + } + } + if (result == null) result = CompletableFuture.failedFuture( + new IllegalStateException("Reward injection returned a null asynchronous result")); + } finally { + user.restoreAsyncActionCollectionScope(actionCollection); + if (previous == null) ACTIVE_REPLAY_STATE.remove(); else ACTIVE_REPLAY_STATE.set(previous); + if (previousKey == null) ACTIVE_REPLAY_KEY.remove(); else ACTIVE_REPLAY_KEY.set(previousKey); + if (previousOccurrenceId == null) ACTIVE_REPLAY_OCCURRENCE_ID.remove(); + else ACTIVE_REPLAY_OCCURRENCE_ID.set(previousOccurrenceId); + } + CompletableFuture combined = new CompletableFuture<>(); + result.whenComplete((value, failure) -> { + user.endAsyncActionCollection(actionCollection).whenComplete((ignored, actionFailure) -> { + if (failure != null) combined.completeExceptionally(failure); + else if (actionFailure != null) combined.completeExceptionally(actionFailure); + else combined.complete(value); + }); + }); + return combined; + } + + private void addPlaceholder(RewardInject inject, Object obj, HashMap placeholders) { + String placeholderName = inject.getPlaceholderName(); + String value = ""; + if (obj instanceof Boolean) { + value = obj.toString(); + } else if (obj instanceof String) { + value = (String) obj; + } else if (obj instanceof Double) { + value = obj.toString(); + } else if (obj instanceof Integer) { + value = obj.toString(); + } + plugin.extraDebug("Adding placeholder " + placeholderName + ":" + value); + placeholders.put(placeholderName, value); + } + + private boolean hasAsyncRewardInjection() { + for (RewardInject inject : plugin.getRewardHandler().getInjectedRewards()) { + if (inject.supportsAsyncRequest() + && (!inject.requiresConfiguredDataForAsync() || isRewardInjectionApplicable(inject))) { + return true; + } + } + return false; + } + + private boolean isRewardInjectionApplicable(RewardInject inject) { + ConfigurationSection data = getConfig().getConfigData(); + return inject.isAlwaysForceNoData() || data.contains(inject.getPath(), true); + } + public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { - if (!AdvancedCorePlugin.getInstance().getOptions().isProcessRewards()) { - AdvancedCorePlugin.getInstance().debug("Processing rewards is disabled"); + if (!plugin.getOptions().isProcessRewards()) { + plugin.debug("Processing rewards is disabled"); return; } @@ -322,7 +1438,8 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { if (plugin.getOptions().isPauseRewards()) { checkRewardFile(); - user.addOfflineRewards(this, rewardOptions.getPlaceholders()); + preserveReplayState(rewardOptions); + user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); plugin.getLogger() .info("Rewards are paused, saving offline reward " + getRewardName() + ": " + user.getPlayerName()); return; @@ -330,7 +1447,8 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { if ((plugin.getOptions().isTreatVanishAsOffline() && user.isVanished())) { checkRewardFile(); - user.addOfflineRewards(this, rewardOptions.getPlaceholders()); + preserveReplayState(rewardOptions); + user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); plugin.getLogger() .info(getRewardName() + ": " + user.getPlayerName() + " is vanished, saving reward offline"); return; @@ -341,7 +1459,8 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { && (!isForceOffline() && !rewardOptions.isForceOffline()))) { if (rewardOptions.isGiveOffline()) { checkRewardFile(); - user.addOfflineRewards(this, rewardOptions.getPlaceholders()); + preserveReplayState(rewardOptions); + user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); plugin.debug("Saving offline reward " + getRewardName() + " for " + user.getPlayerName()); } return; @@ -355,6 +1474,154 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { } } + /** + * Gives this reward and completes when an asynchronous injection chain has + * finished. This is used by nested reward injectors so a child reward cannot + * overtake later parent post-reward injections. + * + *

The established void API deliberately remains fire-and-forget. Callers + * that need ordering or durable replay semantics must use this method.

+ * + * @param user receiving user + * @param rewardOptions reward options + * @return completion stage for the complete reward injection chain + */ + public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) { + boolean primaryThread = false; + try { + primaryThread = Bukkit.isPrimaryThread(); + } catch (IllegalStateException | NullPointerException ignored) { + // Early bootstrap and unit tests may not have an installed Bukkit server. + } + if (!primaryThread) return giveRewardAsyncOffPrimary(user, rewardOptions); + + CompletableFuture> handoff = new CompletableFuture<>(); + try { + RewardOptions requestedOptions = rewardOptions; + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + CompletableFuture result = new CompletableFuture<>(); + // Claim before invoking reward code. A timed-out task that Bukkit later + // runs must not produce side effects for a caller that already retries. + if (!handoff.complete(result)) return; + try { + CompletionStage stage = giveRewardAsyncOffPrimary(user, requestedOptions); + if (stage == null) { + result.completeExceptionally(new IllegalStateException( + "Reward dispatch returned no completion stage")); + return; + } + stage.whenComplete((ignored, failure) -> { + if (failure == null) result.complete(null); + else result.completeExceptionally(failure); + }); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + return handoff.orTimeout(getServerThreadDispatchTimeoutMillis(), TimeUnit.MILLISECONDS) + .thenCompose(stage -> stage); + } + + private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, RewardOptions rewardOptions) { + if (!plugin.getOptions().isProcessRewards()) { + plugin.debug("Processing rewards is disabled"); + if (isDurableReplay(rewardOptions)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Reward processing was disabled before queued delivery")); + } + return CompletableFuture.completedFuture(null); + } + + if (rewardOptions == null) rewardOptions = new RewardOptions(); + if (!rewardOptions.getPlaceholders().containsKey("ExecDate")) { + rewardOptions.addPlaceholder("ExecDate", "" + System.currentTimeMillis()); + } + if (!rewardOptions.getPlaceholders().containsKey("date")) { + try { + LocalDateTime ldt = LocalDateTime.now(); + Date date = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant()); + rewardOptions.addPlaceholder("Date", "" + new SimpleDateFormat( + plugin.getOptions().getFormatRewardTimeFormat()).format(date)); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + PlayerRewardEvent event = new PlayerRewardEvent(this, user, rewardOptions); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + plugin.debug("Reward " + name + " was cancelled for " + user.getPlayerName()); + return CompletableFuture.completedFuture(null); + } + if (rewardOptions.isCheckTimed() && (checkDelayed(user, rewardOptions.getPlaceholders()) + || checkTimed(user, rewardOptions.getPlaceholders()))) { + return CompletableFuture.completedFuture(null); + } + if (!rewardOptions.isOnlineSet()) rewardOptions.setOnline(user.isOnline()); + for (RewardPlaceholderHandle handle : plugin.getRewardHandler().getPlaceholders()) { + if (handle.isPreProcess()) rewardOptions.addPlaceholder(handle.getKey(), handle.getValue(this, user)); + } + + boolean allowOffline = false; + boolean canGive = true; + if (!rewardOptions.isIgnoreRequirements()) { + for (RequirementInject inject : plugin.getRewardHandler().getInjectedRequirements()) { + try { + if (!inject.onRequirementRequest(this, user, getConfig().getConfigData(), rewardOptions)) { + canGive = false; + if (!inject.isAllowReattempt()) return CompletableFuture.completedFuture(null); + allowOffline = true; + } + } catch (Exception e) { + plugin.debug("Failed to check requirement " + inject.getPath()); + e.printStackTrace(); + if (isDurableReplay(rewardOptions)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Failed to evaluate reward requirement " + inject.getPath(), e)); + } + canGive = false; + } + } + } + if (plugin.getOptions().isPauseRewards() || (plugin.getOptions().isTreatVanishAsOffline() && user.isVanished())) { + checkRewardFile(); + preserveReplayState(rewardOptions); + return deferRewardAsync(user, rewardOptions); + } + if (((((!rewardOptions.isOnline() || rewardOptions.getServer() != null) && !user.isOnline()) || allowOffline) + && (!isForceOffline() && !rewardOptions.isForceOffline()))) { + if (rewardOptions.isGiveOffline()) { + checkRewardFile(); + preserveReplayState(rewardOptions); + return deferRewardAsync(user, rewardOptions); + } + return CompletableFuture.completedFuture(null); + } + if (canGive || isForceOffline() || rewardOptions.isForceOffline()) { + plugin.debug(name + ": Passed requirements, attempting to give to " + user.getPlayerName() + "/" + + user.getUUID()); + return giveRewardUserAsync(user, rewardOptions.getPlaceholders(), rewardOptions); + } + return CompletableFuture.completedFuture(null); + } + + private CompletionStage deferRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) { + if (rewardOptions.isTimedQueueReplay()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Timed reward replay remains deferred in its original queue")); + } + if (isDurableReplay(rewardOptions) && rewardOptions.getAsyncReplayCheckpointConsumer() == null) { + return CompletableFuture.failedFuture(new OfflineReplayDeferredException()); + } + user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); + return rewardOptions.getAsyncReplayCheckpointConsumer() == null + ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(new OfflineReplayDeferredException()); + } + /** * Give reward user. * @@ -363,6 +1630,84 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { * @param rewardOptions rewardOptions */ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { + RewardOptions effectiveOptions = rewardOptions == null ? new RewardOptions() : rewardOptions; + if (requiresAwaitedDelivery(effectiveOptions)) { + giveRewardUserAsync(user, phs, effectiveOptions).exceptionally(failure -> { + logRewardUserFailure(failure); + return null; + }); + return; + } + + HashMap placeholders = prepareRewardUser(user, phs); + if (placeholders == null) { + return; + } + giveInjectedRewards(user, placeholders); + plugin.debug("Gave " + user.getPlayerName() + " reward " + name); + } + + /** + * Asynchronously gives a reward to a user when an injection opts into the + * asynchronous API. Preparation remains on the calling thread; only the + * opted-in injection chain is asynchronous. + * + * @param user receiving user + * @param phs placeholders + * @param rewardOptions reward options (reserved for API symmetry) + * @return completion stage that completes after reward injections finish + */ + public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap phs, + RewardOptions rewardOptions) { + ReplayState replayState = replayStateFor(rewardOptions); + String replayKey = rewardOptions.getAsyncReplayKey(); + if (replayKey == null) { + String parentKey = ACTIVE_REPLAY_KEY.get(); + replayKey = parentKey == null ? getRewardName() : parentKey + "/" + getRewardName(); + } + if (!replayState.matchesRegistryFingerprint(injectionRegistryFingerprint(orderedInjectedRewards()))) { + return CompletableFuture.failedFuture(new IncompatibleReplayCheckpointException(replayKey)); + } + String occurrenceId = rewardOptions.getAsyncReplayOccurrenceId(); + if (occurrenceId == null || occurrenceId.isEmpty()) { + occurrenceId = ACTIVE_REPLAY_OCCURRENCE_ID.get(); + // A pre-occurrence persisted entry cannot safely survive an ambiguous + // side effect, so leave it on the legacy compatibility path rather than + // inventing a different id for each retry. + if (occurrenceId == null && rewardOptions.getAsyncReplayCheckpointConsumer() == null) { + occurrenceId = UUID.randomUUID().toString(); + } + } + final HashMap placeholders; + try { + placeholders = prepareRewardUser(user, phs); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + if (placeholders == null) { + if (isDurableReplay(rewardOptions) || hasPersistedReplayCheckpoint(rewardOptions)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Player became unavailable before persisted reward replay")); + } + return CompletableFuture.completedFuture(null); + } + return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, replayKey, + occurrenceId) + .thenRun(() -> plugin.debug("Gave " + user.getPlayerName() + " reward " + name)); + } + + private static boolean hasPersistedReplayCheckpoint(RewardOptions options) { + return options.isLegacyAsyncReplayCheckpoint() || options.getCompletedAsyncInjections() > 0 + || !options.getAsyncReplayProgress().isEmpty() || !options.getAsyncReplayRegistryFingerprints().isEmpty() + || (options.getAsyncReplayState() != null && options.getAsyncReplayState().hasPersistedCheckpoint()); + } + + private boolean requiresAwaitedDelivery(RewardOptions options) { + return options.getAsyncReplayCheckpointConsumer() != null || hasAsyncRewardInjection() + || hasPersistedReplayCheckpoint(options); + } + + private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap phs) { Player player = user.getPlayer(); if (player == null) { @@ -391,14 +1736,19 @@ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, R } } - final HashMap placeholders = new HashMap<>(phs); - - giveInjectedRewards(user, placeholders); - - plugin.debug("Gave " + user.getPlayerName() + " reward " + name); + return new HashMap<>(phs); } else { plugin.debug(getRewardName() + ": Player == null & forceoffline false, player: " + user.getPlayerName() + "/" + user.getUUID()); + return null; + } + } + + private void logRewardUserFailure(Throwable failure) { + if (plugin.getLogger() != null) { + plugin.getLogger().log(Level.WARNING, "Failed to give reward " + getRewardName(), failure); + } else { + failure.printStackTrace(); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java index 0c16f10e0a..21a134e719 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java @@ -6,6 +6,8 @@ import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.OfflinePlayer; import org.bukkit.configuration.ConfigurationSection; @@ -89,6 +91,16 @@ public void send(AdvancedCoreUser user) { } } + /** Sends this builder's reward and completes when its async injection chain finishes. */ + public CompletionStage sendAsync(AdvancedCoreUser user) { + RewardHandler handler = user.getPlugin().getRewardHandler(); + if (reward == null) { + return data == null ? CompletableFuture.completedFuture(null) + : handler.giveRewardAsync(user, data, path, rewardOptions); + } + return handler.giveRewardAsync(user, reward, rewardOptions); + } + public void send(AdvancedCoreUser... users) { for (AdvancedCoreUser user : users) { send(user); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index 9ec68eb337..cb492b4649 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -3,6 +3,10 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; import org.bukkit.Bukkit; import org.bukkit.configuration.Configuration; @@ -101,6 +105,65 @@ public void giveReward(AdvancedCoreUser user, ConfigurationSection data, String } } + /** + * Asynchronous counterpart for nested rewards that must preserve injection + * ordering. The legacy void dispatch methods intentionally remain unchanged. + */ + public CompletionStage giveRewardAsync(AdvancedCoreUser user, ConfigurationSection data, String path, + RewardOptions rewardOptions) { + RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); + RewardOptions options = context.getOptions(); + if (path == null || data == null) return CompletableFuture.completedFuture(null); + if (!plugin.isEnabled()) return disabledDispatch(); + + Reward.ReplayState replayState = Reward.replayStateFor(options); + if (options.getAsyncReplayOccurrenceId() == null) { + String activeOccurrenceId = Reward.currentReplayOccurrenceId(); + if (activeOccurrenceId != null) options.setAsyncReplayOccurrenceId(activeOccurrenceId); + } + String parentReplayKey = options.getAsyncReplayKey(); + if (parentReplayKey == null) parentReplayKey = Reward.currentReplayKey(); + if (parentReplayKey == null) parentReplayKey = "list:" + path; + String nestedLane = "nested-list:" + path; + if (data.isList(path) + || Reward.hasReplayNestedRewardSnapshot(options.getPlaceholders(), nestedLane, replayState, + parentReplayKey)) { + final String stableParentReplayKey = parentReplayKey; + return Reward.replayNestedRewardSequence(plugin, options.getPlaceholders(), nestedLane, + data.isList(path) ? new ArrayList<>(data.getStringList(path)) : java.util.List.of(), replayState, + stableParentReplayKey, (nestedReward, nestedIndex) -> { + // Clone only when this child is actually reached. This lets + // metadata from earlier children be merged before a later + // child receives its isolated ordinary placeholders. + RewardOptions nestedOptions = options.copyForNestedDispatch( + stableParentReplayKey + "/" + nestedReward + ":" + nestedIndex); + nestedOptions.setAsyncReplayState(replayState); + return Reward.continueOnServerThread(plugin, user, + () -> giveRewardAsync(user, nestedReward, nestedOptions)).handle((childResult, failure) -> { + // Child options stay isolated for normal placeholders. Replay + // markers are shared lazily so an earlier child remains durable + // when a later child fails and the list is restarted. + if (Reward.isDurableReplay(options)) { + replayState.mergeReplayMetadataInto(options.getPlaceholders()); + } + if (failure != null) throw new CompletionException(failure); + return childResult; + }); + }); + } + if (data.isConfigurationSection(path)) { + return giveSectionRewardAsync(user, data, path, context); + } + String nestedReward = data.getString(path, ""); + if (nestedReward.isEmpty()) { + return Reward.isDurableReplay(options) + ? CompletableFuture.failedFuture( + new IllegalStateException("Nested replay configuration could not be resolved: " + path)) + : CompletableFuture.completedFuture(null); + } + return giveRewardAsync(user, nestedReward, options); + } + public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null) { @@ -108,7 +171,14 @@ public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewar return; } - if (Bukkit.isPrimaryThread()) { + boolean primaryThread = false; + try { + primaryThread = Bukkit.isPrimaryThread(); + } catch (IllegalStateException | NullPointerException ignored) { + // Unit tests and early bootstrap can call this facade before Bukkit has + // installed a server. There is no primary server thread to leave then. + } + if (primaryThread) { plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> reward.giveReward(user, context.getOptions())); } else { @@ -116,6 +186,66 @@ public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewar } } + public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) { + if (reward == null) return CompletableFuture.completedFuture(null); + if (!plugin.isEnabled()) return disabledDispatch(); + RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); + RewardOptions options = context.getOptions(); + Reward.ReplayState activeState = Reward.currentReplayState(); + if (options.getAsyncReplayState() == null && activeState != null) options.setAsyncReplayState(activeState); + String activeKey = Reward.currentReplayKey(); + if (options.getAsyncReplayKey() == null && activeKey != null) { + options.setAsyncReplayKey(activeKey + "/" + reward.getRewardName()); + } + String activeOccurrenceId = Reward.currentReplayOccurrenceId(); + if (options.getAsyncReplayOccurrenceId() == null && activeOccurrenceId != null) { + options.setAsyncReplayOccurrenceId(activeOccurrenceId); + } + // PlayerRewardEvent is explicitly asynchronous, so Bukkit rejects it when + // a nested reward reaches this dispatcher from an owner thread. Hand only + // reward setup to the async scheduler; injectors marshal their player/world + // work back to the appropriate owner thread and the flattened stage keeps + // the parent replay waiting for the complete child reward. + boolean primaryThread = false; + try { + primaryThread = Bukkit.isPrimaryThread(); + } catch (IllegalStateException | NullPointerException ignored) { + // Unit tests and early bootstrap can call this facade before Bukkit has + // installed a server. There is no primary server thread to leave then. + } + if (!primaryThread) return reward.giveRewardAsync(user, options); + + CompletableFuture> handoff = new CompletableFuture<>(); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + CompletableFuture result = new CompletableFuture<>(); + if (!handoff.complete(result)) return; + try { + CompletionStage stage = reward.giveRewardAsync(user, options); + if (stage == null) { + result.completeExceptionally(new IllegalStateException( + "Reward dispatch returned no completion stage")); + return; + } + stage.whenComplete((ignored, failure) -> { + if (failure == null) result.complete(null); + else result.completeExceptionally(failure); + }); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + return handoff.orTimeout(getPrimaryThreadHandoffTimeoutMillis(), TimeUnit.MILLISECONDS) + .thenCompose(stage -> stage); + } + + protected long getPrimaryThreadHandoffTimeoutMillis() { + return TimeUnit.SECONDS.toMillis(30); + } + public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null || reward.isEmpty()) { @@ -130,6 +260,25 @@ public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewar giveReward(user, handler.getReward(reward), context.getOptions()); } + public CompletionStage giveRewardAsync(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { + RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); + if (reward == null || reward.isEmpty()) return CompletableFuture.completedFuture(null); + if (!plugin.isEnabled()) return disabledDispatch(); + if (reward.startsWith("/")) { + RewardOptions options = context.getOptions(); + return Reward.replayCommandSequence(plugin, context.getPlaceholders(), "direct", java.util.List.of(reward), + Reward.replayStateFor(options), options.getAsyncReplayKey(), + (command, ignoredIndex) -> MiscUtils.getInstance().executeConsoleCommandsAsync( + user.getPlayerName(), command, context.getPlaceholders())); + } + Reward resolved = handler.getReward(reward); + if (resolved == null && Reward.isDurableReplay(context.getOptions())) { + return CompletableFuture.failedFuture( + new IllegalStateException("Nested replay reward could not be resolved: " + reward)); + } + return giveRewardAsync(user, resolved, context.getOptions()); + } + public void givePersistedQueueReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null || reward.isEmpty()) { @@ -177,6 +326,53 @@ public void givePersistedQueueReward(AdvancedCoreUser user, String reward, Rewar giveReward(user, resolved, context.getOptions()); } + /** Resolves and awaits a persisted queue item so a failed async injection can be requeued. */ + public CompletionStage givePersistedQueueRewardAsync(AdvancedCoreUser user, String reward, + RewardOptions rewardOptions) { + RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); + if (reward == null || reward.isEmpty()) return CompletableFuture.completedFuture(null); + if (!plugin.isEnabled()) return disabledDispatch(); + + String rewardName = reward; + Boolean generatedSnapshot = null; + if (reward.startsWith(QUEUED_REFERENCE_PREFIX)) { + String encoded = reward.substring(QUEUED_REFERENCE_PREFIX.length()); + int modeEnd = encoded.indexOf('/'); + if (modeEnd > 0) { + String mode = encoded.substring(0, modeEnd); + String encodedName = encoded.substring(modeEnd + 1); + if ((mode.equals("snapshot") || mode.equals("normal")) && !encodedName.isEmpty()) { + try { + rewardName = new String(Base64.getUrlDecoder().decode(encodedName), StandardCharsets.UTF_8); + generatedSnapshot = Boolean.valueOf(mode.equals("snapshot")); + } catch (IllegalArgumentException ignored) { + rewardName = reward; + } + } + } + } + Reward resolved; + if (generatedSnapshot != null) { + resolved = generatedSnapshot.booleanValue() ? handler.getQueuedGeneratedReward(rewardName, user.getUUID()) + : handler.getReward(rewardName); + } else if (handler.rewardExist(rewardName) || handler.hasDirectRewardHandle(rewardName)) { + resolved = handler.getReward(rewardName); + } else { + resolved = handler.getQueuedGeneratedReward(rewardName, user.getUUID()); + if (resolved == null) resolved = handler.getReward(rewardName); + } + if (resolved == null) { + return CompletableFuture.failedFuture( + new IllegalStateException("Persisted queued reward could not be resolved: " + rewardName)); + } + return giveRewardAsync(user, resolved, context.getOptions()); + } + + private CompletionStage disabledDispatch() { + return CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before asynchronous reward dispatch")); + } + public void updateReward(Configuration data, String path, RewardOptions rewardOptions) { if (data == null || path == null || !data.isConfigurationSection(path)) { return; @@ -228,4 +424,21 @@ private void giveSectionReward(AdvancedCoreUser user, ConfigurationSection data, + user.getUUID()); giveReward(user, reward, options); } + + private CompletionStage giveSectionRewardAsync(AdvancedCoreUser user, ConfigurationSection data, String path, + RewardExecutionContext context) { + RewardOptions options = context.getOptions(); + String rewardName = context.buildRewardName(path); + DirectlyDefinedReward direct = handler.getDirectlyDefined(path); + SubDirectlyDefinedReward sub = handler.getSubDirectlyDefined(rewardName); + SubRewardResolver resolver = handler.getSubRewardResolver(); + SubDirectlyDefinedReward fileSub = resolver == null ? null : resolver.getFileBackedSubReward(rewardName); + if (context.supportsDirectDispatch() && (direct != null || sub != null || fileSub != null)) { + Reward selected = direct != null ? direct.getReward() : (sub != null ? sub : fileSub).getReward(); + return giveRewardAsync(user, selected, options); + } + Reward selected = new Reward(rewardName, data.getConfigurationSection(path)); + selected.checkRewardFile(); + return giveRewardAsync(user, selected, options); + } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java index 885e062615..4312db52ea 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java @@ -7,6 +7,7 @@ import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -184,14 +185,27 @@ public void giveReward(AdvancedCoreUser user, ConfigurationSection data, String rewardExecutor.giveReward(user, data, path, rewardOptions); } + public CompletionStage giveRewardAsync(AdvancedCoreUser user, ConfigurationSection data, String path, + RewardOptions rewardOptions) { + return rewardExecutor.giveRewardAsync(user, data, path, rewardOptions); + } + public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) { rewardExecutor.giveReward(user, reward, rewardOptions); } + public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) { + return rewardExecutor.giveRewardAsync(user, reward, rewardOptions); + } + public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { rewardExecutor.giveReward(user, reward, rewardOptions); } + public CompletionStage giveRewardAsync(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { + return rewardExecutor.giveRewardAsync(user, reward, rewardOptions); + } + public void givePersistedQueueReward(AdvancedCoreUser user, PersistedQueueReference rewardReference, RewardOptions rewardOptions) { if (rewardReference == null) { @@ -200,6 +214,14 @@ public void givePersistedQueueReward(AdvancedCoreUser user, PersistedQueueRefere rewardExecutor.givePersistedQueueReward(user, rewardReference.getReference(), rewardOptions); } + public CompletionStage givePersistedQueueRewardAsync(AdvancedCoreUser user, + PersistedQueueReference rewardReference, RewardOptions rewardOptions) { + if (rewardReference == null) { + return java.util.concurrent.CompletableFuture.completedFuture(null); + } + return rewardExecutor.givePersistedQueueRewardAsync(user, rewardReference.getReference(), rewardOptions); + } + public boolean hasDirectRewardHandle(String reward) { return rewardRegistry.hasDirectRewardHandle(reward); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java index 014c611637..bcda82489a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java @@ -1,7 +1,9 @@ package com.bencodez.advancedcore.api.rewards; -import java.util.HashMap; -import java.util.Map.Entry; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Consumer; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.simpleapi.array.ArrayUtils; @@ -41,8 +43,60 @@ public class RewardOptions { @Getter private boolean useDefaultWorlds = true; - @Getter - private long orginalTrigger = -1; + @Getter + private long orginalTrigger = -1; + + /** + * Number of completed injection stages recorded for a persisted replay. This + * is intentionally internal queue metadata: normal reward dispatch always + * starts at zero. + */ + @Getter + @Setter + private int completedAsyncInjections; + + @Getter + @Setter + private Map asyncReplayProgress = new HashMap<>(); + + @Getter + @Setter + private Map asyncReplayRegistryFingerprints = new HashMap<>(); + + /** True when this queued entry used the old count-only replay format. */ + @Getter + @Setter + private boolean legacyAsyncReplayCheckpoint; + + @Getter + @Setter + private Reward.ReplayState asyncReplayState; + + /** + * Stable execution path for a nested asynchronous reward. The path is queue + * metadata only; callers that do not participate in replay leave it unset. + */ + @Getter + @Setter + private String asyncReplayKey; + + /** + * Stable identity for one logical queued reward occurrence. It is distinct + * from {@link #asyncReplayKey}, which identifies a stage path shared by + * independent executions of the same reward definition. + */ + @Getter + @Setter + private String asyncReplayOccurrenceId; + + @Getter + @Setter + private Consumer asyncReplayCheckpointConsumer; + + /** True while replaying an entry that must remain in the timed queue on deferral. */ + @Getter + @Setter + private boolean timedQueueReplay; public RewardOptions() { } @@ -172,11 +226,36 @@ public String toString() { } - public RewardOptions withPlaceHolder(HashMap placeholders2) { + public RewardOptions withPlaceHolder(HashMap placeholders2) { for (Entry entry : placeholders2.entrySet()) { placeholders.put(entry.getKey(), entry.getValue()); } - return this; - } - -} + return this; + } + + /** + * Makes an isolated option object for one member of an asynchronously + * dispatched list. Mutable placeholders and replay metadata must not leak + * from one list member into the next. + */ + RewardOptions copyForNestedDispatch(String replayKey) { + RewardOptions copy = new RewardOptions().setCheckTimed(checkTimed).setGiveOffline(giveOffline) + .setIgnoreChance(ignoreChance).setIgnoreRequirements(ignoreRequirements).setPrefix(prefix).setSuffix(suffix) + .orginalTrigger(orginalTrigger).setPlaceholders(new HashMap<>(placeholders)); + if (forceOffline) copy.forceOffline(); + if (!useDefaultWorlds) copy.disableDefaultWorlds(); + if (onlineSet) copy.setOnline(online); + if (!server.isEmpty()) copy.setServer(server); + copy.setAsyncReplayState(asyncReplayState); + copy.setAsyncReplayRegistryFingerprints(new HashMap<>(asyncReplayRegistryFingerprints)); + copy.setLegacyAsyncReplayCheckpoint(legacyAsyncReplayCheckpoint); + copy.setAsyncReplayKey(replayKey); + copy.setAsyncReplayOccurrenceId(asyncReplayOccurrenceId); + // The checkpoint consumer belongs to the queued parent occurrence. Nested + // children share its ReplayState but must never replace or complete that + // queue entry independently. + copy.setTimedQueueReplay(timedQueueReplay); + return copy; + } + +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java index bbba30eb1b..763f34d2c6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java @@ -34,7 +34,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat section.getInt("Delay", 30)); return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("ActionBar") { + }.requiresPlayerWhen((data, placeholders) -> !data.getString("ActionBar.Message", "").isEmpty()) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("ActionBar") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java index fc1bb37eb7..09560aaea0 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -28,6 +30,20 @@ private RewardAdvancedPriority() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedPriority") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -46,6 +62,40 @@ public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, Conf return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward sourceReward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + String selectedKey = Reward.replaySelection(placeholders, () -> { + for (String key : section.getKeys(false)) { + RewardOptions namingOptions = new RewardOptions() + .setPrefix(sourceReward.getName() + "_AdvancedPriority"); + Reward reward = handler.getReward(section, key, namingOptions); + if (reward != null && reward.canGiveReward(user, + new RewardOptions().withPlaceHolder(placeholders))) { + return key; + } + } + return null; + }); + if (selectedKey == null) return CompletableFuture.completedFuture(null); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String occurrenceId = Reward.currentReplayOccurrenceId(); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + Reward selected = handler.getReward(section, selectedKey, new RewardOptions() + .setPrefix(sourceReward.getName() + "_AdvancedPriority")); + if (selected == null) return CompletableFuture.failedFuture(new IllegalStateException( + "Selected advanced priority reward could not be resolved: " + selectedKey)); + RewardOptions childOptions = Reward.withReplayState(new RewardOptions().setIgnoreChance(true) + .setIgnoreRequirements(true).setPrefix(sourceReward.getName() + "_AdvancedPriority") + .withPlaceHolder(placeholders), replayState, replayKey, + "selected:" + selectedKey, occurrenceId); + return handler.giveRewardAsync(user, selected, childOptions); + }))).thenApply(ignored -> selectedKey); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java index 1003eab2bc..3059ac1ac3 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java @@ -4,6 +4,8 @@ import java.util.HashMap; import java.util.Set; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -31,6 +33,20 @@ private RewardAdvancedRandomReward() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedRandomReward") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -45,6 +61,29 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + ArrayList rewards = ArrayUtils.convert(section.getKeys(false)); + if (rewards.isEmpty() && !hasPendingReplayWork(placeholders)) { + return CompletableFuture.completedFuture(null); + } + String selected = Reward.replaySelection(placeholders, + () -> rewards.get(ThreadLocalRandom.current().nextInt(rewards.size()))); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + RewardOptions childOptions = Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), + replayState, replayKey, "selected:" + selected, + Reward.currentReplayOccurrenceId()) + .setPrefix(reward.getRewardName() + "_AdvancedRandomReward"); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, + () -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, section, selected, childOptions)))) + .thenApply(ignored -> selected); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java index f3701c64ff..9268ac270f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -30,6 +32,30 @@ private RewardAdvancedRewards() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedRewards") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public boolean requiresConfiguredDataForAsync() { + return true; + } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + String lane = "advanced-rewards:" + getPath(); + return Reward.hasReplayNestedRewardSnapshot(placeholders, lane) + && !Reward.hasCompletedNestedRewardSequence(placeholders, lane); + } + + @Override + public boolean supportsAsyncSynchronization() { + // This injector awaits nested rewards, which can invoke this same + // shared instance. Serializing that recursive chain would deadlock. + return false; + } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -42,6 +68,35 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!data.isConfigurationSection(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) + && !isAlwaysForceNoData()) { + return hasPendingReplayWork(placeholders) + ? CompletableFuture.failedFuture(new IllegalStateException( + "Pending reward replay configuration is missing: " + getPath())) + : CompletableFuture.completedFuture(null); + } + com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); + String parentReplayKey = Reward.currentReplayKey(); + String parentOccurrenceId = Reward.currentReplayOccurrenceId(); + ConfigurationSection section = data.getConfigurationSection(getPath()); + ArrayList configured = ArrayUtils.convert(section.getKeys(false)); + return Reward.replayNestedRewardSequence(plugin, placeholders, "advanced-rewards:" + getPath(), + configured, replayState, parentReplayKey, (rewardName, childIndex) -> { + if (!section.contains(rewardName, true)) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Pending nested reward configuration is missing: " + rewardName)); + } + return Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, section, rewardName, + Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), replayState, + parentReplayKey, rewardName + ":" + childIndex, parentOccurrenceId) + .setPrefix(reward.getRewardName() + "_AdvancedRewards"))); + }).thenApply(ignored -> (Object) null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java index f0e7cdaa78..f403545e35 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -29,10 +31,26 @@ private RewardAdvancedWorld() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedWorld") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + String lane = "advanced-world:" + getPath(); + return Reward.hasReplayNestedRewardSnapshot(placeholders, lane) + && !Reward.hasCompletedNestedRewardSequence(placeholders, lane); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { - for (String key : section.getKeys(false)) { + for (String key : section.getKeys(false)) { plugin.extraDebug("AdvancedWorld: Giving reward " + sourceReward.getName() + "_AdvancedWorld"); section.set(key + ".Worlds", ArrayUtils.convert(new String[] { key })); handler.giveReward(user, section, key, new RewardOptions().withPlaceHolder(placeholders) @@ -41,6 +59,28 @@ public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, Conf return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward sourceReward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); + String parentReplayKey = Reward.currentReplayKey(); + String parentOccurrenceId = Reward.currentReplayOccurrenceId(); + return Reward.replayNestedRewardSequence(plugin, placeholders, "advanced-world:" + getPath(), + new ArrayList<>(section.getKeys(false)), replayState, parentReplayKey, (key, childIndex) -> { + if (!section.contains(key, true)) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Pending nested reward configuration is missing: " + key)); + } + return Reward.continueOnServerThread(plugin, user, () -> { + section.set(key + ".Worlds", ArrayUtils.convert(new String[] { key })); + return handler.giveRewardAsync(user, section, key, + Reward.withReplayState(new RewardOptions().withPlaceHolder(placeholders), replayState, + parentReplayKey, key + ":" + childIndex, parentOccurrenceId) + .setPrefix(sourceReward.getRewardName() + "_AdvancedWorld")); + }); + }).thenApply(ignored -> (String) null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java index 1c959ab3cc..92ae4017f3 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java @@ -34,7 +34,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder("DRAGON_HEAD"), new EditGUIValueInventory("BossBar") { + }.requiresPlayerWhen((data, placeholders) -> data.getBoolean("BossBar.Enabled")) + .addEditButton(new EditGUIButton(new ItemBuilder("DRAGON_HEAD"), new EditGUIValueInventory("BossBar") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java index 34494a81d6..9fcef0a893 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -13,6 +15,7 @@ import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.rewards.DefinedReward; import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardBuilder; import com.bencodez.advancedcore.api.rewards.RewardEditData; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.SubDirectlyDefinedReward; @@ -29,6 +32,20 @@ private RewardChoices() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectBoolean("EnableChoices") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, boolean value, HashMap placeholders) { @@ -45,6 +62,45 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, boolean valu return null; } + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, boolean value, + HashMap placeholders) { + if (!value && !hasPendingReplayWork(placeholders)) return CompletableFuture.completedFuture(null); + String occurrenceId = Reward.currentReplaySideEffectOccurrenceId(); + String choice = Reward.replaySelection(placeholders, + () -> value ? user.getChoicePreference(reward.getName()) : null); + if (choice == null || choice.isEmpty() || choice.equalsIgnoreCase("none")) { + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, () -> { + user.addUnClaimedChoiceReward(reward.getName(), occurrenceId); + return CompletableFuture.completedFuture(null); + })); + } + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String replayOccurrence = Reward.currentReplayOccurrenceId(); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), + reward.getConfig().getChoicesRewardsPath(choice)).withPrefix(reward.getName()) + .withPlaceHolder(placeholders).withPlaceHolder("choice", choice); + Reward.withReplayState(builder.getRewardOptions(), replayState, replayKey, + "choice:" + choice, replayOccurrence); + return builder.sendAsync(user); + }))).thenApply(ignored -> choice); + } + + @Override + public CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, + String occurrenceId, String injectionKey) { + String choiceOccurrence = Reward.replaySideEffectOccurrenceId(occurrenceId, injectionKey); + return Reward.continueOnServerThread(plugin, user, () -> { + user.checkpointUnClaimedChoiceReward(choiceOccurrence); + return CompletableFuture.completedFuture(null); + }); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java index ca8cf3bf0a..46882a7ece 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java @@ -3,10 +3,13 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ThreadLocalRandom; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import com.bencodez.advancedcore.AdvancedCorePlugin; @@ -44,6 +47,24 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { public static void registerNumberCommand(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("NumberCommand") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayCommandSnapshot(placeholders, "console"); + } + + @Override + protected CompletionStage onMissingConfiguredDataAsync(Reward reward, AdvancedCoreUser user, + HashMap placeholders) { + return onRewardRequestedAsync(reward, user, new YamlConfiguration(), placeholders) + .thenApply(result -> result); + } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -54,6 +75,19 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat MiscUtils.getInstance().executeConsoleCommands(user.getPlayerName(), command, placeholders); return String.valueOf(number); } + + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + int min = section.getInt("Min", 0); + int max = section.getInt("Max", 100); + String number = Reward.replaySelection(placeholders, + () -> String.valueOf(ThreadLocalRandom.current().nextInt(min, max + 1))); + if (number == null) return CompletableFuture.completedFuture(null); + String command = section.getString("Command", "").replace("%number%", number); + return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), command, placeholders) + .thenApply(ignored -> number); + } }.asPlaceholder("Number").priority(100).validator(new RewardInjectValidator() { @Override public void onValidate(Reward reward, RewardInject inject, ConfigurationSection data) { @@ -76,12 +110,30 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection public static void registerCommand(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectString("Command") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayCommandSnapshot(placeholders, "console"); + } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, String value, HashMap placeholders) { MiscUtils.getInstance().executeConsoleCommands(user.getPlayerName(), value, placeholders); return null; } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, String value, + HashMap placeholders) { + return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), value, placeholders) + .thenApply(ignored -> null); + } }.addEditButton(new EditGUIButton(new ItemBuilder("COMMAND_BLOCK"), new EditGUIValueString("Command", null) { @Override public void setValue(Player player, String value) { @@ -103,6 +155,17 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection public static void registerCommands(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectStringList("Commands") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayCommandSnapshot(placeholders, "console"); + } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { @@ -111,6 +174,13 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ArrayList list, HashMap placeholders) { + return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), list, placeholders, true) + .thenApply(ignored -> null); + } }.addEditButton(new EditGUIButton(new ItemBuilder("COMMAND_BLOCK"), new EditGUIValueList("Commands", null) { @Override public void setValue(Player player, ArrayList value) { @@ -137,6 +207,25 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection })); handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Commands") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayCommandSnapshot(placeholders, "console") + || Reward.hasReplayCommandSnapshot(placeholders, "player"); + } + + @Override + protected CompletionStage onMissingConfiguredDataAsync(Reward reward, AdvancedCoreUser user, + HashMap placeholders) { + return onRewardRequestedAsync(reward, user, new YamlConfiguration(), placeholders) + .thenApply(result -> result); + } + @SuppressWarnings("unchecked") @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, @@ -152,6 +241,30 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } + + @SuppressWarnings("unchecked") + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + ArrayList consoleCommands = (ArrayList) section.getList("Console", new ArrayList<>()); + ArrayList userCommands = (ArrayList) section.getList("Player", new ArrayList<>()); + boolean stagger = section.getBoolean("Stagger", true); + boolean playerCommandsPending = Reward.hasReplayCommandSnapshot(placeholders, "player") + ? Reward.hasPendingReplayCommandWork(placeholders, "player") + : !userCommands.isEmpty(); + CompletionStage availability = playerCommandsPending + ? user.validatePlayerCommandAvailabilityAsync() + : CompletableFuture.completedFuture(null); + // Validate the player before any mixed-section side effect, then preserve + // the established console-before-player command order. + return availability.thenCompose(ignored -> consoleCommands.isEmpty() + && !Reward.hasReplayCommandSnapshot(placeholders, "console") + ? CompletableFuture.completedFuture(null) + : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, + placeholders, stagger)) + .thenCompose(ignored -> user.preformCommandAsync(userCommands, placeholders)) + .thenApply(ignored -> null); + } }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("Commands.Console", null) { @Override public void setValue(Player player, ArrayList value) { @@ -174,6 +287,17 @@ public void setValue(Player player, ArrayList value) { public static void registerRandomCommand(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectStringList("RandomCommand") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayCommandSnapshot(placeholders, "console"); + } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { @@ -183,6 +307,18 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ArrayList list, HashMap placeholders) { + if (list.isEmpty() && !hasPendingReplayWork(placeholders)) { + return CompletableFuture.completedFuture(null); + } + String command = Reward.replaySelection(placeholders, + () -> list.get(ThreadLocalRandom.current().nextInt(list.size()))); + return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), command, placeholders) + .thenApply(ignored -> null); + } }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("RandomCommand", null) { @Override public void setValue(Player player, ArrayList value) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java index 3927491463..fb65895d31 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java @@ -33,7 +33,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.DIAMOND), new EditGUIValueInventory("Effect") { + }.requiresPlayerWhen((data, placeholders) -> data.getBoolean("Effect.Enabled")) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.DIAMOND), new EditGUIValueInventory("Effect") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java index 8e7fdf0e0f..f0b0f60874 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java @@ -31,10 +31,11 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, HashMap placeholders) { - user.giveExp(num); + if (num != 0) user.giveExp(num); return null; } - }.asPlaceholder("EXP").priority(100).addEditButton( + }.requiresPlayerWhen((data, placeholders) -> data.getInt("EXP", 0) != 0) + .asPlaceholder("EXP").priority(100).addEditButton( new EditGUIButton(new ItemBuilder("EXPERIENCE_BOTTLE"), new EditGUIValueInventory("EXP") { @Override public void openInventory(ClickEvent clickEvent) { @@ -54,10 +55,11 @@ public void setVal(String key, Object value) { @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, HashMap placeholders) { - user.giveExpLevels(num); + if (num != 0) user.giveExpLevels(num); return null; } - }.asPlaceholder("EXP").priority(100).addEditButton( + }.requiresPlayerWhen((data, placeholders) -> data.getInt("EXPLevels", 0) != 0) + .asPlaceholder("EXP").priority(100).addEditButton( new EditGUIButton(new ItemBuilder("EXPERIENCE_BOTTLE"), new EditGUIValueInventory("EXPLevels") { @Override public void openInventory(ClickEvent clickEvent) { @@ -79,11 +81,12 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat HashMap placeholders) { int minExp = section.getInt("Min", 0); int maxExp = section.getInt("Max", 0); - int value = ThreadLocalRandom.current().nextInt(minExp, maxExp); + int value = Integer.parseInt(Reward.replaySelection(placeholders, + () -> Integer.toString(ThreadLocalRandom.current().nextInt(minExp, maxExp)))); user.giveExp(value); return "" + value; } - }.asPlaceholder("EXP").priority(100).validator(rangeValidator("EXP"))); + }.requiresPlayer().asPlaceholder("EXP").priority(100).validator(rangeValidator("EXP"))); handler.getInjectedRewards().add(new RewardInjectConfigurationSection("EXPLevels") { @Override @@ -91,11 +94,12 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat HashMap placeholders) { int minExp = section.getInt("Min", 0); int maxExp = section.getInt("Max", 0); - int value = ThreadLocalRandom.current().nextInt(minExp, maxExp); + int value = Integer.parseInt(Reward.replaySelection(placeholders, + () -> Integer.toString(ThreadLocalRandom.current().nextInt(minExp, maxExp)))); user.giveExpLevels(value); return "" + value; } - }.asPlaceholder("EXP").priority(100).validator(rangeValidator("EXPLevels"))); + }.requiresPlayer().asPlaceholder("EXP").priority(100).validator(rangeValidator("EXPLevels"))); } private static RewardInjectValidator zeroValidator(String message) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java index 4d24d7097a..38469887b1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java @@ -40,7 +40,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder("FIREWORK_ROCKET"), new EditGUIValueInventory("Firework") { + }.requiresPlayerWhen((data, placeholders) -> data.getBoolean("Firework.Enabled")) + .addEditButton(new EditGUIButton(new ItemBuilder("FIREWORK_ROCKET"), new EditGUIValueInventory("Firework") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java index 55ad2ea430..eeda55541a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java @@ -2,9 +2,12 @@ import java.util.HashMap; import java.util.Set; +import java.util.function.Supplier; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent; @@ -43,12 +46,14 @@ public static void registerItem(RewardHandler handler, AdvancedCorePlugin plugin @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { - ItemBuilder builder = new ItemBuilder(section); - builder.setCheckLoreLength(false); - user.giveItem(builder); + replayItem(user, placeholders, "payload", () -> { + ItemBuilder builder = new ItemBuilder(section); + builder.setCheckLoreLength(false); + return builder; + }); return null; } - }.validator(itemValidator())); + }.requiresPlayer().validator(itemValidator())); } public static void registerRandomItem(RewardHandler handler, AdvancedCorePlugin plugin) { @@ -57,15 +62,24 @@ public static void registerRandomItem(RewardHandler handler, AdvancedCorePlugin public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set section, ConfigurationSection data, HashMap placeholders) { if (!section.isEmpty()) { - String item = ArrayUtils.pickRandom(ArrayUtils.convert(section)); - ItemBuilder builder = new ItemBuilder(data.getConfigurationSection(item)); - builder.setCheckLoreLength(false); - user.giveItem(builder); + String item = Reward.replaySelection(placeholders, + () -> ArrayUtils.pickRandom(ArrayUtils.convert(section))); + replayItem(user, placeholders, "payload:" + item, () -> { + ConfigurationSection selected = data.getConfigurationSection(item); + if (selected == null) { + throw new IllegalStateException("Selected random item is no longer configured: " + item); + } + ItemBuilder builder = new ItemBuilder(selected); + builder.setCheckLoreLength(false); + return builder; + }); return item; } return null; } - }.asPlaceholder("RandomItem").priority(90).validator(randomItemValidator())); + }.requiresPlayerWhen((data, placeholders) -> data.isConfigurationSection("RandomItem") + && !data.getConfigurationSection("RandomItem").getKeys(false).isEmpty()) + .asPlaceholder("RandomItem").priority(90).validator(randomItemValidator())); } public static void registerItems(RewardHandler handler, AdvancedCorePlugin plugin) { @@ -76,18 +90,22 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set { + ItemBuilder builder = new ItemBuilder(data.getConfigurationSection(item)); + builder.setCheckLoreLength(false); + return builder.setPlaceholders(placeholders); + }); + debug("Giving item " + item + ":" + selected.item()); + if (selected.chancePassed() && oneChance) { return item; } } } return ""; } - }.priority(90).asPlaceholder("Item").validator(itemsValidator().addPath("OnlyOneItemChance")) + }.requiresPlayerWhen((data, placeholders) -> data.isConfigurationSection("Items") + && !data.getConfigurationSection("Items").getKeys(false).isEmpty()) + .priority(90).asPlaceholder("Item").validator(itemsValidator().addPath("OnlyOneItemChance")) .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("Items") { @Override public void openInventory(ClickEvent clickEvent) { @@ -101,9 +119,51 @@ public void setVal(String key, Object value) { } }.open(clickEvent.getPlayer(), reward); } - }.addLore("Edit items")))); + }.addLore("Edit items")))); } + private static ReplayedItem replayItem(AdvancedCoreUser user, HashMap placeholders, String lane, + Supplier builder) { + org.bukkit.entity.Player player = user.getPlayer(); + if (player == null) { + // Preserve the legacy deferred-delivery path. Durable replay records this + // action as not started if the player remains unavailable, so a later retry + // may safely build and freeze the payload before its first side effect. + ItemBuilder deferred = builder.get(); + user.giveItem(deferred); + return new ReplayedItem(null, deferred.isChancePass()); + } + String serialized = Reward.replaySelection(placeholders, lane, + () -> { + ItemBuilder selected = builder.get(); + return serializeItem(selected.toItemStack(player), selected.isChancePass()); + }); + if (serialized == null) return new ReplayedItem(null, false); + if (!serialized.startsWith("v1:")) { + throw new IllegalStateException("Persisted item reward payload has an unsupported format"); + } + YamlConfiguration frozen = new YamlConfiguration(); + try { + frozen.loadFromString(serialized.substring(3)); + } catch (Exception failure) { + throw new IllegalStateException("Persisted item reward payload is invalid", failure); + } + ItemStack item = frozen.getItemStack("Item"); + if (item == null) throw new IllegalStateException("Persisted item reward payload is missing"); + user.giveItem(item); + return new ReplayedItem(item, frozen.getBoolean("ChancePassed", item.getAmount() > 0)); + } + + private static String serializeItem(ItemStack item, boolean chancePassed) { + if (item == null) return null; + YamlConfiguration frozen = new YamlConfiguration(); + frozen.set("Item", item); + frozen.set("ChancePassed", chancePassed); + return "v1:" + frozen.saveToString(); + } + + private record ReplayedItem(ItemStack item, boolean chancePassed) { } + private static RewardInjectValidator itemValidator() { return new RewardInjectValidator() { @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java index d80fcd9f66..04e908f58d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -55,6 +56,20 @@ public void setValue(Player player, ArrayList value) { }.addLore("Javascript expressions to run")))); handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Javascript") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -70,6 +85,29 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + if (!section.getBoolean("Enabled") && !hasPendingReplayWork(placeholders)) { + return java.util.concurrent.CompletableFuture.completedFuture(null); + } + String path = Reward.replaySelection(placeholders, + () -> new JavascriptEngine().addPlayer(user.getOfflinePlayer()).addPlaceholders(placeholders) + .getBooleanValue(section.getString("Expression")) ? "TrueRewards" : "FalseRewards"); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String occurrenceId = Reward.currentReplayOccurrenceId(); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + RewardBuilder builder = new RewardBuilder(section, path) + .withPrefix(reward.getName() + ".Javascript").withPlaceHolder(placeholders); + Reward.withReplayState(builder.getRewardOptions(), replayState, + replayKey, "path:" + path, occurrenceId); + return builder.sendAsync(user); + }))).thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java index fd6fcdf3c0..d8e5f86688 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java @@ -4,6 +4,8 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map.Entry; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -35,6 +37,21 @@ private RewardLucky() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Lucky") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders) + && !Reward.hasCompletedNestedRewardSequence(placeholders, "lucky:" + getPath()); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -71,6 +88,43 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + HashMap luckyRewards = new HashMap<>(); + for (String key : section.getKeys(false)) { + if (MessageAPI.isInt(key)) { + int num = Integer.parseInt(key); + if (num > 0) luckyRewards.put(num, "Lucky." + num); + } + } + String choices = Reward.replaySelection(placeholders, () -> { + HashMap selected = new LinkedHashMap<>(); + for (Entry entry : luckyRewards.entrySet()) { + if (MiscUtils.getInstance().checkChance(1, entry.getKey())) selected.put(entry.getValue(), entry.getKey()); + } + selected = ArrayUtils.sortByValuesStr(selected, false); + ArrayList paths = new ArrayList<>(selected.keySet()); + if (reward.getConfig().getConfigData().getBoolean("OnlyOneLucky", false) && paths.size() > 1) { + paths.subList(1, paths.size()).clear(); + } + return paths.isEmpty() ? null : String.join("\n", paths); + }); + if (choices == null) return CompletableFuture.completedFuture(null); + com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); + String parentReplayKey = Reward.currentReplayKey(); + String parentOccurrenceId = Reward.currentReplayOccurrenceId(); + return Reward.replayNestedRewardSequence(plugin, placeholders, "lucky:" + getPath(), + java.util.List.of(choices.split("\\n")), replayState, parentReplayKey, (path, childIndex) -> + Reward.continueOnServerThread(plugin, user, () -> { + RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), path) + .withPrefix(reward.getName()).withPlaceHolder(placeholders); + Reward.withReplayState(builder.getRewardOptions(), replayState, parentReplayKey, + path + ":" + childIndex, parentOccurrenceId); + return builder.sendAsync(user); + })).thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java index 1195dac8a7..cd4fb85b16 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java @@ -36,7 +36,8 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, String value user.sendMessage(value, placeholders); return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder("OAK_SIGN"), new EditGUIValueInventory("Messages") { + }.requiresPlayerWhen((data, placeholders) -> !data.getString("Message", "").isEmpty()) + .addEditButton(new EditGUIButton(new ItemBuilder("OAK_SIGN"), new EditGUIValueInventory("Messages") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); @@ -56,27 +57,27 @@ public void setVal(String key, Object value) { public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList value, HashMap placeholders) { user.sendMessage(value, placeholders); - return null; - } - }); + return null; + } + }.requiresPlayerWhen((data, placeholders) -> !data.getStringList("Messages.Player").isEmpty())); - handler.getInjectedRewards().add(new RewardInjectStringList("Message") { + handler.getInjectedRewards().add(new RewardInjectStringList("Message") { @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList value, HashMap placeholders) { user.sendMessage(value, placeholders); - return null; - } - }); + return null; + } + }.requiresPlayerWhen((data, placeholders) -> !data.getStringList("Message").isEmpty())); handler.getInjectedRewards().add(new RewardInjectStringList("RandomMessage") { @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList value, HashMap placeholders) { user.sendMessage(value.get(ThreadLocalRandom.current().nextInt(0, value.size())), placeholders); - return null; - } - }); + return null; + } + }.requiresPlayerWhen((data, placeholders) -> !data.getStringList("RandomMessage").isEmpty())); handler.getInjectedRewards().add(new RewardInjectString("Messages.Player") { @Override @@ -85,7 +86,8 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, String value user.sendMessage(value, placeholders); return null; } - }.validator(new RewardInjectValidator() { + }.requiresPlayerWhen((data, placeholders) -> !data.getString("Messages.Player", "").isEmpty()) + .validator(new RewardInjectValidator() { @Override public void onValidate(Reward reward, RewardInject inject, ConfigurationSection data) { if (data.isString(inject.getPath()) && data.getString(inject.getPath()).isEmpty()) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMoney.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMoney.java index bcba6e022b..3fbd2137e9 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMoney.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMoney.java @@ -65,7 +65,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat HashMap placeholders) { double minMoney = section.getDouble("Min", 0); double maxMoney = section.getDouble("Max", 0); - double value = ThreadLocalRandom.current().nextDouble(minMoney, maxMoney); + double value = Double.parseDouble(Reward.replaySelection(placeholders, + () -> Double.toString(ThreadLocalRandom.current().nextDouble(minMoney, maxMoney)))); if (section.getBoolean("Round")) { value = Math.round(value); user.giveMoney(value); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java index d51f07ebad..86df65ff0a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java @@ -33,7 +33,9 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Potions") { + }.requiresPlayerWhen((data, placeholders) -> data.isConfigurationSection("Potions") + && !data.getConfigurationSection("Potions").getKeys(false).isEmpty()) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Potions") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java index 21a6b98ab6..fe33f6f7b8 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.entity.Player; @@ -25,6 +27,20 @@ private RewardPriority() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectStringList("Priority") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequest(Reward source, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { @@ -38,6 +54,37 @@ public String onRewardRequest(Reward source, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward source, AdvancedCoreUser user, + ArrayList list, HashMap placeholders) { + String selectedName = Reward.replaySelection(placeholders, () -> { + for (String rewardName : list) { + Reward reward = handler.getReward(rewardName); + if (reward != null && reward.canGiveReward(user, + new RewardOptions().withPlaceHolder(placeholders))) { + return rewardName; + } + } + return null; + }); + if (selectedName == null) return CompletableFuture.completedFuture(null); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String occurrenceId = Reward.currentReplayOccurrenceId(); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + Reward selected = handler.getReward(selectedName); + if (selected == null) return CompletableFuture.failedFuture(new IllegalStateException( + "Selected priority reward could not be resolved: " + selectedName)); + RewardOptions childOptions = Reward.withReplayState( + new RewardOptions().withPlaceHolder(placeholders).setIgnoreChance(true) + .setIgnoreRequirements(true), replayState, replayKey, + "selected:" + selectedName, occurrenceId); + return handler.giveRewardAsync(user, selected, childOptions); + }))).thenApply(ignored -> selectedName); + } }.asPlaceholder("Priority").addEditButton( new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("Priority", null) { @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java index e63f8cbb37..73be4f7744 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -24,6 +26,20 @@ private RewardRandom() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Random") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @SuppressWarnings("unchecked") @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, @@ -48,6 +64,46 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @SuppressWarnings("unchecked") + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + String selection = Reward.replaySelection(placeholders, () -> { + if (!MiscUtils.getInstance().checkChance(section.getDouble("Chance", 100), 100)) return "fallback"; + if (!section.getBoolean("PickRandom", true)) return "rewards"; + ArrayList rewards = (ArrayList) section.getList("Rewards", new ArrayList<>()); + return rewards == null || rewards.isEmpty() ? "none" + : "pick:" + rewards.get(ThreadLocalRandom.current().nextInt(rewards.size())); + }); + if (selection == null || selection.equals("none")) return CompletableFuture.completedFuture(null); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String occurrenceId = Reward.currentReplayOccurrenceId(); + if (selection.startsWith("pick:")) { + String selected = selection.substring("pick:".length()); + RewardOptions childOptions = Reward.withReplayState( + new RewardOptions().setPlaceholders(placeholders), replayState, + replayKey, "selected:" + selected, occurrenceId); + return selected.isEmpty() ? CompletableFuture.completedFuture(null) + : Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, + () -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, selected, childOptions)))) + .thenApply(ignored -> null); + } + String path = selection.equals("rewards") ? "Random.Rewards" : "Random.FallBack"; + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), path) + .withPrefix(reward.getName()).withPlaceHolder(placeholders); + Reward.withReplayState(builder.getRewardOptions(), replayState, + replayKey, "path:" + path, occurrenceId); + return builder.sendAsync(user); + }))).thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java index 03dab009e5..4b866b0467 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ThreadLocalRandom; import org.bukkit.Material; @@ -25,6 +26,20 @@ private RewardRandomReward() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectStringList("RandomReward") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { @@ -35,6 +50,27 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ArrayList list, HashMap placeholders) { + if (list.isEmpty() && !hasPendingReplayWork(placeholders)) { + return java.util.concurrent.CompletableFuture.completedFuture(null); + } + String selected = Reward.replaySelection(placeholders, + () -> list.get(ThreadLocalRandom.current().nextInt(list.size()))); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + RewardOptions childOptions = Reward.withReplayState( + new RewardOptions().setPlaceholders(placeholders), replayState, + replayKey, "selected:" + selected, Reward.currentReplayOccurrenceId()); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, + () -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, selected, childOptions)))) + .thenApply(ignored -> selected); + } }.asPlaceholder("RandomReward").priority(20).addEditButton( new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("RandomReward", null) { @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java index a2e9f4e7b4..98ea368872 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java @@ -37,7 +37,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.NOTE_BLOCK), new EditGUIValueInventory("Sound") { + }.requiresPlayerWhen((data, placeholders) -> data.getBoolean("Sound.Enabled")) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.NOTE_BLOCK), new EditGUIValueInventory("Sound") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java index ae5be20a20..9f8c2c03cf 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java @@ -7,6 +7,8 @@ import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -34,6 +36,20 @@ private RewardSpecialChance() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("SpecialChance") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -71,6 +87,59 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + double totalChance = 0; + LinkedHashMap map = new LinkedHashMap<>(); + for (String key : section.getKeys(false)) { + String path = key; + key = key.replaceAll("_", "."); + if (MessageAPI.isDouble(key)) { + double chance = Double.valueOf(key); + totalChance += chance; + map.put(chance, path); + } + } + Set> copy = new HashSet<>(map.entrySet()); + double currentNum = 0; + map.clear(); + for (Entry entry : copy) { + currentNum += entry.getKey(); + map.put(currentNum, entry.getValue()); + } + final double chanceTotal = totalChance; + String selection = Reward.replaySelection(placeholders, () -> { + double randomNum = ThreadLocalRandom.current().nextDouble(chanceTotal); + for (Entry entry : map.entrySet()) { + if (randomNum <= entry.getKey()) return entry.getValue() + "\n" + entry.getKey(); + } + return null; + }); + if (selection == null) return CompletableFuture.completedFuture(null); + String[] selected = selection.split("\n", 2); + if (selected.length != 2) return CompletableFuture.completedFuture(null); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String occurrenceId = Reward.currentReplayOccurrenceId(); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + for (Entry entry : map.entrySet()) { + if (entry.getValue().equals(selected[0])) { + RewardBuilder builder = new RewardBuilder(section, entry.getValue()) + .withPrefix(reward.getName() + "_SpecialChance").withPlaceHolder(placeholders) + .withPlaceHolder("chance", selected[1]); + Reward.withReplayState(builder.getRewardOptions(), replayState, replayKey, + "path:" + entry.getValue(), occurrenceId); + return builder.sendAsync(user); + } + } + return CompletableFuture.failedFuture(new IllegalStateException( + "Selected special-chance reward could not be resolved: " + selected[0])); + }))).thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java index bc681f1fc7..0f070584f1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -27,6 +28,23 @@ private RewardSubRewards() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Rewards") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public boolean requiresConfiguredDataForAsync() { + return true; + } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + String lane = "nested-list:" + getPath(); + return Reward.hasReplayNestedRewardSnapshot(placeholders, lane) + && !Reward.hasCompletedNestedRewardSequence(placeholders, lane); + } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -36,6 +54,20 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!data.isConfigurationSection(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) + && !isAlwaysForceNoData()) { + return hasPendingReplayWork(placeholders) + ? java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException( + "Pending reward replay configuration is missing: " + getPath())) + : java.util.concurrent.CompletableFuture.completedFuture(null); + } + return new RewardBuilder(reward.getConfig().getConfigData(), "Rewards").withPrefix(reward.getName()) + .withPlaceHolder(placeholders).sendAsync(user).thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java index 01928de69b..7e43379583 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java @@ -24,6 +24,16 @@ private RewardTempPermission() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("TempPermission") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public boolean requiresConfiguredDataForAsync() { + return true; + } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -31,6 +41,10 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat int time = section.getInt("Expiration"); if (!permission.isEmpty()) { if (time > 0) { + if (user.getPlayer() == null) { + throw new IllegalStateException( + "Player became unavailable before temporary permission delivery"); + } user.addPermission(permission, time); } else { extraDebug("Time is 0"); @@ -40,7 +54,9 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("TempPermission") { + }.requiresPlayerWhen((data, placeholders) -> !data.getString("TempPermission.Permission", "").isEmpty() + && data.getInt("TempPermission.Expiration") > 0) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("TempPermission") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java index 0af6995d95..8ac292c56d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java @@ -35,7 +35,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Title") { + }.requiresPlayerWhen((data, placeholders) -> data.getBoolean("Title.Enabled")) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Title") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java index 521facb4c3..9c43766adf 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java @@ -1,7 +1,11 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.ArrayList; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.BiPredicate; +import java.util.function.Supplier; import org.bukkit.configuration.ConfigurationSection; @@ -16,7 +20,8 @@ import lombok.Getter; import lombok.Setter; -public abstract class RewardInject extends Inject { +public abstract class RewardInject extends Inject { + private CompletableFuture synchronizedAsyncTail = CompletableFuture.completedFuture(null); @Getter private boolean addAsPlaceholder = false; @@ -40,8 +45,13 @@ public abstract class RewardInject extends Inject { @Setter private boolean synchronize = false; - @Getter - private boolean alwaysValid = false; + @Getter + private boolean alwaysValid = false; + + @Getter + private boolean playerRequired; + private BiPredicate> playerRequiredWhen = + (data, placeholders) -> true; @Getter private RewardInjectValidator validate; @@ -85,21 +95,143 @@ public void extraDebug(String str) { AdvancedCorePlugin.getInstance().extraDebug(str); } - public boolean hasValidator() { - return getValidate() != null; - } + public boolean hasValidator() { + return getValidate() != null; + } + + /** + * Whether this injection has an asynchronous implementation. Existing + * injections remain synchronous by default. + * + * @return true when {@link #onRewardRequestAsync(Reward, AdvancedCoreUser, + * ConfigurationSection, HashMap)} should be used + */ + public boolean supportsAsyncRequest() { + return false; + } + + /** + * Whether this asynchronous implementation is relevant only when its + * configured path exists. Existing third-party injectors keep the historic + * always-async opt-in behavior unless they explicitly opt into this guard. + */ + public boolean requiresConfiguredDataForAsync() { + return false; + } + + /** + * Whether durable replay metadata requires this injection to run even when its + * configured path was removed or changed while the reward was queued. + */ + public boolean hasPendingReplayWork(HashMap placeholders) { + return false; + } + + /** + * Whether asynchronous dispatch may serialize this injection across reward + * chains. Nested reward injectors opt out because awaiting a child that uses + * the same shared injection would otherwise wait on its own unfinished tail. + */ + public boolean supportsAsyncSynchronization() { + return true; + } public boolean isEditable() { return !getEditButtons().isEmpty(); } - public abstract Object onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, - HashMap placeholders); + public abstract Object onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, + HashMap placeholders); + + /** + * Asynchronously evaluates this injection. The default implementation keeps + * the compatibility behavior by wrapping the existing synchronous callback. + * + * @param reward reward being given + * @param user receiving user + * @param data reward configuration + * @param placeholders current placeholders + * @return completion stage containing the injection result + */ + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequest(reward, user, data, placeholders)); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } + + /** + * Notifies this injection after its replay progress has been durably persisted. + * Implementations may use the stable occurrence and injection identities to + * retire their own idempotency records. A failed callback keeps the replay + * pending, but the persisted checkpoint prevents the injection itself from + * running again; recovery retries only this callback. Implementations must be + * idempotent because recovery can repeat a notification that already succeeded. + * + * @param reward reward being given + * @param user receiving user + * @param occurrenceId stable identity of this logical reward occurrence + * @param injectionKey stable replay path for this injection + */ + public CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, String occurrenceId, + String injectionKey) { + return CompletableFuture.completedFuture(null); + } + + /** Serializes a synchronized asynchronous injection through completion, not just invocation. */ + public synchronized CompletionStage runSynchronizedAsync(Supplier> request) { + CompletableFuture result = new CompletableFuture<>(); + synchronizedAsyncTail = synchronizedAsyncTail.handle((ignored, previousFailure) -> null) + .thenCompose(ignored -> { + CompletionStage stage; + try { + stage = request.get(); + if (stage == null) throw new IllegalStateException("Asynchronous reward injection returned null"); + } catch (Throwable failure) { + result.completeExceptionally(failure); + return CompletableFuture.completedFuture(null); + } + return stage.handle((value, failure) -> { + if (failure == null) result.complete(value); + else result.completeExceptionally(failure); + return (Void) null; + }); + }).toCompletableFuture(); + return result; + } - public RewardInject postReward() { + public RewardInject postReward() { postReward = true; return this; - } + } + + /** Marks a legacy injection whose side effect cannot succeed without a live player. */ + public RewardInject requiresPlayer() { + playerRequired = true; + return this; + } + + /** + * Marks a player-bound injection and limits the requirement to payloads that + * actually produce player output. Pending replay work remains player-bound even + * if the current configuration no longer enables that output. + */ + public RewardInject requiresPlayerWhen( + BiPredicate> playerRequiredWhen) { + this.playerRequired = true; + this.playerRequiredWhen = java.util.Objects.requireNonNull(playerRequiredWhen, "playerRequiredWhen"); + return this; + } + + /** Whether this configured or pending payload currently needs a live player. */ + public boolean isPlayerRequiredFor(ConfigurationSection data, HashMap placeholders) { + if (!playerRequired) return false; + if (hasPendingReplayWork(placeholders)) return true; + if (!isAlwaysForceNoData() && !data.contains(getPath(), true)) return false; + return playerRequiredWhen.test(data, placeholders); + } public RewardInject priority(int priority) { setPriority(priority); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectBoolean.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectBoolean.java index 077ead5ace..75dc295fe7 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectBoolean.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectBoolean.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.HashMap; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -26,8 +28,18 @@ public RewardInjectBoolean(String path, boolean defaultValue) { this.defaultValue = defaultValue; } - public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, boolean num, - HashMap placeholders); + public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, boolean num, + HashMap placeholders); + + /** Completion-aware counterpart for boolean injections. */ + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, boolean value, + HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequest(reward, user, value, placeholders)); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, @@ -42,7 +54,21 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio } return re; } - return null; - } - -} + return null; + } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!(data.isBoolean(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) + || isAlwaysForceNoData())) { + if (!hasPendingReplayWork(placeholders)) return CompletableFuture.completedFuture(null); + } + boolean value = data.getBoolean(getPath(), isDefaultValue()); + CompletionStage result = onRewardRequestAsync(reward, user, value, placeholders); + if (result == null) return CompletableFuture.failedFuture(new IllegalStateException( + "Reward injection returned a null asynchronous result: " + getPath())); + return result.thenApply(valueResult -> (Object) (valueResult == null ? String.valueOf(value) : valueResult)); + } + +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java index 8a2d6bb889..9b3f87a4fd 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.HashMap; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -25,7 +27,39 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio return null; } - public abstract String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, - HashMap placeholders); + public abstract String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, + HashMap placeholders); + + /** Completion-aware counterpart for section injections. */ + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequested(reward, user, section, placeholders)); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + + /** Fails closed when a durable section replay can no longer read its payload. */ + protected CompletionStage onMissingConfiguredDataAsync(Reward reward, AdvancedCoreUser user, + HashMap placeholders) { + return CompletableFuture.failedFuture( + new IllegalStateException("Pending reward replay configuration is missing: " + getPath())); + } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!data.isConfigurationSection(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) + && !isAlwaysForceNoData()) { + if (!hasPendingReplayWork(placeholders) + || Reward.hasCompletedSingleNestedReward(placeholders, "selected")) { + return CompletableFuture.completedFuture(null); + } + return onMissingConfiguredDataAsync(reward, user, placeholders); + } + return onRewardRequestedAsync(reward, user, data.getConfigurationSection(getPath()), placeholders) + .thenApply(result -> result); + } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectInt.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectInt.java index 8d9400cd20..3b30d74e81 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectInt.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectInt.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.HashMap; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -26,8 +28,8 @@ public RewardInjectInt(String path, int defaultValue) { this.defaultValue = defaultValue; } - @Override - public String onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, + @Override + public String onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, HashMap placeholders) { if (data.isInt(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) { int value = data.getInt(getPath(), getDefaultValue()); @@ -39,10 +41,51 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio } return re; } - return null; - } + return null; + } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (data.isInt(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) { + int value = data.getInt(getPath(), getDefaultValue()); + AdvancedCorePlugin.getInstance() + .extraDebug(reward.getRewardName() + ": Giving " + getPath() + ", value: " + value); + try { + CompletionStage result = onRewardRequestAsync(reward, user, value, placeholders); + if (result == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Reward injection returned a null asynchronous result: " + getPath())); + } + return result.thenApply(valueResult -> valueResult == null ? "" + value : valueResult) + .thenApply(valueResult -> (Object) valueResult); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } + return CompletableFuture.completedFuture(null); + } - public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, - HashMap placeholders); + public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, + HashMap placeholders); + + /** + * Typed asynchronous hook for integer injections. Subclasses can override it + * without reimplementing configuration parsing. + * + * @param reward reward being given + * @param user receiving user + * @param num configured integer value + * @param placeholders current placeholders + * @return completion stage containing the typed result + */ + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, int num, + HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequest(reward, user, num, placeholders)); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java index e43c7d1f08..cfb954b911 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.HashMap; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -43,7 +45,33 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio return null; } - public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, String value, - HashMap placeholders); - -} + public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, String value, + HashMap placeholders); + + /** Completion-aware counterpart for string injections. */ + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, String value, + HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequest(reward, user, value, placeholders)); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!((data.isString(getPath()) && !data.getString(getPath(), "").isEmpty()) + || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData())) { + if (!hasPendingReplayWork(placeholders)) return CompletableFuture.completedFuture(null); + } + String value = data.getString(getPath(), getDefaultValue()); + CompletionStage result = onRewardRequestAsync(reward, user, value, placeholders); + if (result == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Reward injection returned a null asynchronous result: " + getPath())); + } + return result.thenApply(valueResult -> (Object) (valueResult == null ? value : valueResult)); + } + +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java index c92f2638b4..995efabd9a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java @@ -1,7 +1,10 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.ArrayList; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -28,12 +31,26 @@ public RewardInjectStringList(String path, ArrayList defaultValue) { this.defaultValue = defaultValue; } - public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList num, - HashMap placeholders); + public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList num, + HashMap placeholders); + + /** + * Completion-aware counterpart for list injections. Existing list injections + * retain their synchronous behavior unless they opt in by overriding this + * method. + */ + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, ArrayList value, + HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequest(reward, user, value, placeholders)); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } @SuppressWarnings("unchecked") @Override - public String onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, + public String onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, HashMap placeholders) { if (data.isList(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) { ArrayList value = (ArrayList) data.getList(getPath(), getDefaultValue()); @@ -43,6 +60,21 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio } return null; - } + } + + @SuppressWarnings("unchecked") + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!data.isList(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) && !isAlwaysForceNoData()) { + return hasPendingReplayWork(placeholders) + ? onRewardRequestAsync(reward, user, new ArrayList<>(), placeholders).thenApply(result -> result) + : CompletableFuture.completedFuture(null); + } + List stored = data.getList(getPath(), getDefaultValue()); + ArrayList value = new ArrayList<>(); + for (Object entry : stored) value.add(String.valueOf(entry)); + return onRewardRequestAsync(reward, user, value, placeholders).thenApply(result -> result); + } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 666e041500..b6a3e2a352 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java @@ -1,5 +1,6 @@ package com.bencodez.advancedcore.api.user; +import java.lang.reflect.Array; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.time.Duration; @@ -10,9 +11,19 @@ import java.util.Base64; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; import java.util.Map.Entry; import java.util.UUID; +import java.util.WeakHashMap; import java.util.concurrent.TimeUnit; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; +import java.util.function.Supplier; import org.bukkit.Bukkit; import org.bukkit.Effect; @@ -22,6 +33,7 @@ import org.bukkit.Registry; import org.bukkit.Sound; import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.serialization.ConfigurationSerializable; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.metadata.MetadataValue; @@ -56,6 +68,377 @@ public class AdvancedCoreUser { private static final String QUEUED_REFERENCE_PREFIX = "\\AdvancedCoreQueue/1/"; + private static final String ASYNC_PROGRESS_DELIMITER = "%asyncprogress%"; + private static final String ASYNC_RETRY_DELIMITER = "%asyncretry%"; + private static final String ASYNC_OCCURRENCE_DELIMITER = "%asyncoccurrence%"; + private static final String CHOICE_OCCURRENCE_PREFIX = "\\AdvancedCoreChoice/1/"; + private static final String CHECKPOINTED_CHOICE_OCCURRENCE_PREFIX = "\\AdvancedCoreChoice/2/"; + private static final String CONSUMED_CHOICE_OCCURRENCE_PREFIX = "\\AdvancedCoreChoice/3/"; + private static final Object REPLAY_CLAIMS_LOCK = new Object(); + private static final WeakHashMap> REPLAY_CLAIMS = new WeakHashMap<>(); + private static final ThreadLocal ASYNC_ACTION_COLLECTION = new ThreadLocal<>(); + + /** Internal completion scope used by asynchronous reward dispatch. */ + public static final class AsyncActionCollection { + private static final String ACTION_SNAPSHOT_SUFFIX = "_snapshot"; + private static final String ACTION_COMPLETION_VERSION = "v2:"; + private static final String ACTION_SNAPSHOT_VERSION = "v1:"; + private final AsyncActionCollection previous; + private final AdvancedCoreUser owner; + private final ArrayList actions = new ArrayList<>(); + private final HashMap actionOccurrences = new HashMap<>(); + private final Reward.ReplayState replayState; + private final HashMap placeholders; + private final String checkpointKey; + private final String snapshotKey; + private final AdvancedCorePlugin plugin; + private boolean closed; + + private AsyncActionCollection(AsyncActionCollection previous, AdvancedCoreUser owner, Reward.ReplayState replayState, + HashMap placeholders, String injectionKey, AdvancedCorePlugin plugin) { + this.previous = previous; + this.owner = owner; + this.replayState = replayState; + this.placeholders = placeholders; + this.checkpointKey = Reward.legacyActionReplayKey(injectionKey); + this.snapshotKey = checkpointKey + ACTION_SNAPSHOT_SUFFIX; + this.plugin = plugin; + } + + private synchronized boolean add(Supplier> action, String descriptor) { + if (closed) return false; + String fingerprint = Reward.legacyActionFingerprint(descriptor); + int occurrence = actionOccurrences.getOrDefault(fingerprint, 0); + actionOccurrences.put(fingerprint, occurrence + 1); + actions.add(new ReplayAction(fingerprint + "/" + occurrence, fingerprint, action, + !descriptor.startsWith("failure:"))); + return true; + } + + private boolean belongsTo(AdvancedCoreUser user) { + return owner == user; + } + + private synchronized CompletionStage closeAndAwait() { + closed = true; + HashMap currentSnapshot = new HashMap<>(); + for (ReplayAction action : actions) { + if (action.durable) currentSnapshot.put(action.identity, action.fingerprint); + } + HashMap persistedSnapshot; + HashSet completed; + try { + persistedSnapshot = snapshot(readReplayValue(snapshotKey)); + completed = completed(readReplayValue(checkpointKey)); + for (String identity : completed) { + if (!persistedSnapshot.containsKey(identity)) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Legacy action checkpoint does not match its persisted action snapshot")); + } + } + } catch (IllegalArgumentException failure) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Cannot safely resume legacy reward actions from an ordinal-only or malformed checkpoint", failure)); + } + boolean snapshotChanged = persistedSnapshot.isEmpty() && !currentSnapshot.isEmpty(); + if (snapshotChanged) persistedSnapshot = new HashMap<>(currentSnapshot); + else if (!persistedSnapshot.equals(currentSnapshot)) { + for (Entry persisted : persistedSnapshot.entrySet()) { + if (!completed.contains(persisted.getKey()) + && !persisted.getValue().equals(currentSnapshot.get(persisted.getKey()))) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Cannot safely resume because an unfinished legacy reward action changed or disappeared")); + } + } + // The actions are deliberately matched individually below. Retaining the + // original snapshot lets a reordered, shortened, or extended config skip + // only the exact effects known to have completed. New actions are added + // before they can receive a completion marker. + persistedSnapshot.putAll(currentSnapshot); + snapshotChanged = true; + } + CompletionStage result = CompletableFuture.completedFuture(null); + if (snapshotChanged) { + recordReplayValue(snapshotKey, encodeSnapshot(persistedSnapshot)); + result = checkpoint(); + } + HashMap snapshotLedger = persistedSnapshot; + for (ReplayAction action : actions) { + if (completed.contains(action.identity)) continue; + result = result.thenCompose(ignored -> { + try { + CompletionStage stage = action.action.get(); + if (stage == null) return CompletableFuture.failedFuture( + new IllegalStateException("Scheduled reward action returned null")); + return stage.exceptionallyCompose(failure -> { + Throwable cause = unwrapCompletionFailure(failure); + if (!(cause instanceof LegacyActionNotStartedException)) { + return CompletableFuture.failedFuture(cause); + } + // Scheduler rejection, shutdown-before-dispatch, and the bounded + // pre-dispatch timeout all prove the action never began. Release its + // reservation so a retry may safely regenerate a random payload. + snapshotLedger.remove(action.identity); + recordReplayValue(snapshotKey, encodeSnapshot(snapshotLedger)); + return checkpoint().thenCompose(unused -> CompletableFuture.failedFuture(cause)); + }); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + }).thenCompose(ignored -> { + if (!action.durable) return CompletableFuture.completedFuture(null); + completed.add(action.identity); + recordReplayValue(checkpointKey, encodeCompleted(completed)); + return checkpoint(); + }); + } + return result; + } + + private static Throwable unwrapCompletionFailure(Throwable failure) { + Throwable current = failure; + while ((current instanceof java.util.concurrent.CompletionException + || current instanceof java.util.concurrent.ExecutionException) && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private String readReplayValue(String key) { + return Reward.replayMetadata(placeholders, replayState, key); + } + + private void recordReplayValue(String key, String value) { + Reward.recordReplayMetadata(placeholders, replayState, key, value); + } + + private CompletionStage checkpoint() { + if (replayState == null || placeholders == null) return CompletableFuture.completedFuture(null); + return replayState.persistCheckpointAsync(plugin, placeholders); + } + + private static HashSet completed(String encoded) { + HashSet values = new HashSet<>(); + if (encoded == null || encoded.isEmpty()) return values; + if (!encoded.startsWith(ACTION_COMPLETION_VERSION)) { + throw new IllegalArgumentException("Unknown legacy action checkpoint version"); + } + for (String value : encoded.substring(ACTION_COMPLETION_VERSION.length()).split("\\.", -1)) { + if (value.isEmpty()) continue; + values.add(new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8)); + } + return values; + } + + private static String encodeCompleted(HashSet completed) { + ArrayList values = new ArrayList<>(completed); + java.util.Collections.sort(values); + StringBuilder encoded = new StringBuilder(ACTION_COMPLETION_VERSION); + for (String value : values) { + if (encoded.length() > ACTION_COMPLETION_VERSION.length()) encoded.append('.'); + encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8))); + } + return encoded.toString(); + } + + private static HashMap snapshot(String encoded) { + HashMap values = new HashMap<>(); + if (encoded == null || encoded.isEmpty()) return values; + if (!encoded.startsWith(ACTION_SNAPSHOT_VERSION)) { + throw new IllegalArgumentException("Unknown legacy action snapshot version"); + } + for (String entry : encoded.substring(ACTION_SNAPSHOT_VERSION.length()).split("\\.", -1)) { + if (entry.isEmpty()) continue; + String[] pair = entry.split("~", 2); + if (pair.length != 2 || values.put(new String(Base64.getUrlDecoder().decode(pair[0]), StandardCharsets.UTF_8), + new String(Base64.getUrlDecoder().decode(pair[1]), StandardCharsets.UTF_8)) != null) { + throw new IllegalArgumentException("Malformed legacy action snapshot"); + } + } + return values; + } + + private static String encodeSnapshot(HashMap snapshot) { + ArrayList identities = new ArrayList<>(snapshot.keySet()); + java.util.Collections.sort(identities); + StringBuilder encoded = new StringBuilder(ACTION_SNAPSHOT_VERSION); + for (String identity : identities) { + if (encoded.length() > ACTION_SNAPSHOT_VERSION.length()) encoded.append('.'); + encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(identity.getBytes(StandardCharsets.UTF_8))).append('~') + .append(Base64.getUrlEncoder().withoutPadding().encodeToString( + snapshot.get(identity).getBytes(StandardCharsets.UTF_8))); + } + return encoded.toString(); + } + + private static final class ReplayAction { + private final String identity; + private final String fingerprint; + private final Supplier> action; + private final boolean durable; + + private ReplayAction(String identity, String fingerprint, Supplier> action, + boolean durable) { + this.identity = identity; + this.fingerprint = fingerprint; + this.action = action; + this.durable = durable; + } + } + } + + /** Signals a replay-aware legacy action that was conclusively never started. */ + private static final class LegacyActionNotStartedException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + private LegacyActionNotStartedException(String message) { + super(message); + } + + private LegacyActionNotStartedException(String message, Throwable cause) { + super(message, cause); + } + } + + /** Internal cross-package signal for a replay action proven not to have begun. */ + public static RuntimeException replayActionNotStarted(String message) { + return new LegacyActionNotStartedException(message); + } + + /** Internal cross-package signal for scheduler rejection before a replay action began. */ + public static RuntimeException replayActionNotStarted(String message, Throwable cause) { + return new LegacyActionNotStartedException(message, cause); + } + + /** Returns whether a completion failed before its replay-aware action began. */ + public static boolean isReplayActionNotStarted(Throwable failure) { + return AsyncActionCollection.unwrapCompletionFailure(failure) instanceof LegacyActionNotStartedException; + } + + /** + * Explicitly carries an async injector's originating action scope into a + * completion callback. Injectors capture this while their request method is + * invoked, then wrap only callbacks whose legacy user actions belong to that + * request. It deliberately does not infer ownership from other pending work + * on the callback thread. + */ + public static final class AsyncActionContext { + private final AsyncActionCollection collection; + + private AsyncActionContext(AsyncActionCollection collection) { + this.collection = collection; + } + + /** Wraps a CompletionStage callback that has no return value. */ + public Runnable wrap(Runnable callback) { + if (callback == null) throw new IllegalArgumentException("callback cannot be null"); + return () -> runInScope(() -> { + callback.run(); + return null; + }); + } + + /** Wraps a CompletionStage mapping callback while preserving its result. */ + public Function wrap(Function callback) { + if (callback == null) throw new IllegalArgumentException("callback cannot be null"); + return value -> runInScope(() -> callback.apply(value)); + } + + /** Wraps a deferred supplier used by an asynchronous injector. */ + public Supplier wrap(Supplier callback) { + if (callback == null) throw new IllegalArgumentException("callback cannot be null"); + return () -> runInScope(callback); + } + + private T runInScope(Supplier callback) { + if (collection == null) return callback.get(); + AsyncActionCollection previous = ASYNC_ACTION_COLLECTION.get(); + ASYNC_ACTION_COLLECTION.set(collection); + try { + return callback.get(); + } finally { + if (previous == null) ASYNC_ACTION_COLLECTION.remove(); + else ASYNC_ACTION_COLLECTION.set(previous); + } + } + } + + /** Starts collecting completion stages created by legacy reward callbacks. */ + public AsyncActionCollection beginAsyncActionCollection() { + return beginAsyncActionCollection(null, null, null); + } + + /** Starts a collection whose legacy actions advance the current replay checkpoint individually. */ + public AsyncActionCollection beginAsyncActionCollection(Reward.ReplayState replayState, + HashMap placeholders, String injectionKey) { + AsyncActionCollection collection = new AsyncActionCollection(ASYNC_ACTION_COLLECTION.get(), this, replayState, + placeholders, injectionKey, plugin); + ASYNC_ACTION_COLLECTION.set(collection); + return collection; + } + + /** + * Captures this user's active injector scope for an explicitly wrapped async + * continuation. Calling this outside the originating injector returns an + * inert context, so unrelated work keeps its ordinary scheduling semantics. + */ + public AsyncActionContext captureAsyncActionContext() { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + return new AsyncActionContext(collection != null && collection.belongsTo(this) ? collection : null); + } + + /** Restores the calling thread's previous collection while keeping this scope active for its returned stage. */ + public void restoreAsyncActionCollectionScope(AsyncActionCollection collection) { + if (collection == null || ASYNC_ACTION_COLLECTION.get() != collection) return; + if (collection.previous == null) ASYNC_ACTION_COLLECTION.remove(); + else ASYNC_ACTION_COLLECTION.set(collection.previous); + } + + /** Closes a completed async scope and returns completion for every collected action. */ + public CompletionStage endAsyncActionCollection(AsyncActionCollection collection) { + if (collection == null) return CompletableFuture.completedFuture(null); + restoreAsyncActionCollectionScope(collection); + return collection.closeAndAwait(); + } + + private boolean collectAsyncAction(CompletionStage action) { + return collectAsyncAction(() -> action, "failure:" + action.getClass().getName()); + } + + private boolean collectAsyncAction(Supplier> action, String descriptor) { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + if (collection != null && collection.belongsTo(this) && collection.add(action, descriptor)) return true; + // Do not infer ownership from another pending collection. A normal synchronous + // reward can run while an unrelated async injection is waiting; it must retain + // its established fire-and-forget scheduling semantics instead of being made a + // dependency of whichever injection completes next on this thread. + return false; + } + + private boolean hasOwnedAsyncActionCollection() { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + return collection != null && collection.belongsTo(this); + } + + /** + * Retained for binary compatibility with integrations that previously called + * this internal hand-off hook. Unscoped actions are intentionally not claimed: + * only actions created while their originating collection is active may become + * part of that collection. + */ + public void claimAsyncContinuationActions(AsyncActionCollection collection) { } + + private void collectAsyncFailure(Throwable failure) { + collectAsyncAction(CompletableFuture.failedFuture(failure)); + } + + private static final class ReplayClaims { + private final HashMap offline = new HashMap<>(); + private final HashSet timed = new HashSet<>(); + private CompletableFuture serialReplayTail = CompletableFuture.completedFuture(null); + } /** * User data fetch mode for this user. @@ -214,9 +597,38 @@ public AdvancedCoreUser(AdvancedCorePlugin plugin, UUID uuid, String playerName) * @param placeholders the placeholders */ public void addOfflineRewards(Reward reward, HashMap placeholders) { + addOfflineRewards(reward, placeholders, null); + } + + /** + * Adds an offline reward while retaining an in-flight asynchronous replay's + * occurrence and durable checkpoint. This overload is used when pause or + * vanish handling defers a reward before its async chain can complete. + * + * @param reward the reward + * @param placeholders the placeholders + * @param options replay options to preserve, if any + */ + public void addOfflineRewards(Reward reward, HashMap placeholders, RewardOptions options) { synchronized (plugin) { ArrayList offlineRewards = getOfflineRewards(); - offlineRewards.add(queuedRewardReference(reward) + "%placeholders%" + ArrayUtils.makeString(placeholders)); + Reward.preserveReplayState(options); + HashMap savedPlaceholders = placeholders == null ? new HashMap<>() + : new HashMap<>(placeholders); + if (options != null) savedPlaceholders.putAll(options.getPlaceholders()); + String queued = queuedRewardReference(reward, options) + "%placeholders%" + + ArrayUtils.makeString(savedPlaceholders); + String occurrence = options == null ? null : options.getAsyncReplayOccurrenceId(); + int replacement = -1; + if (occurrence != null && !occurrence.isEmpty()) { + for (int index = offlineRewards.size() - 1; index >= 0; index--) { + if (!occurrence.equals(occurrenceId(offlineRewards.get(index)))) continue; + replacement = index; + offlineRewards.remove(index); + } + } + if (replacement < 0) offlineRewards.add(queued); + else offlineRewards.add(Math.min(replacement, offlineRewards.size()), queued); setOfflineRewards(offlineRewards); } } @@ -237,7 +649,11 @@ public void addPermission(String permission) { * @param delay the delay in milliseconds */ public void addPermission(String permission, long delay) { - plugin.getPermissionHandler().addPermission(getPlayer(), permission, delay); + Player player = getPlayer(); + if (scheduleOwnedPlayerAction(player, + () -> plugin.getPermissionHandler().addPermission(player, permission, delay), + "temporary-permission:" + permission + ":" + delay)) return; + plugin.getPermissionHandler().addPermission(player, permission, delay); } /** @@ -248,21 +664,288 @@ public void addPermission(String permission, long delay) { * @param epochMilli the epoch time in milliseconds when the reward should be * given */ - public synchronized void addTimedReward(Reward reward, HashMap placeholders, long epochMilli) { - HashMap timed = getTimedRewards(); - String rewardName = queuedRewardReference(reward); - rewardName += "%extime%" + System.currentTimeMillis(); + public void addTimedReward(Reward reward, HashMap placeholders, long epochMilli) { + synchronized (plugin) { + HashMap timed = new HashMap<>(getTimedRewards()); + String rewardName = queuedRewardReference(reward); + rewardName += "%extime%" + System.currentTimeMillis(); - timed.put(rewardName + "%placeholders%" + ArrayUtils.makeString(placeholders), epochMilli); - setTimedRewards(timed); + timed.put(rewardName + "%placeholders%" + ArrayUtils.makeString(placeholders), epochMilli); + setTimedRewards(timed); + } loadTimedDelayedTimer(epochMilli); } private String queuedRewardReference(Reward reward) { + return queuedRewardReference(reward, null); + } + + private String queuedRewardReference(Reward reward, RewardOptions options) { String encodedName = Base64.getUrlEncoder().withoutPadding() .encodeToString(reward.getRewardName().getBytes(StandardCharsets.UTF_8)); - return QUEUED_REFERENCE_PREFIX + (reward.isGeneratedSnapshotCreated() ? "snapshot/" : "normal/") - + encodedName; + String reference = QUEUED_REFERENCE_PREFIX + (reward.isGeneratedSnapshotCreated() ? "snapshot/" : "normal/") + + encodedName + ASYNC_OCCURRENCE_DELIMITER + + (options == null || options.getAsyncReplayOccurrenceId() == null + || options.getAsyncReplayOccurrenceId().isEmpty() ? UUID.randomUUID() + : options.getAsyncReplayOccurrenceId()); + if (options != null) { + String serialized = encodeAsyncReplayProgress(options.getAsyncReplayProgress(), + options.getAsyncReplayRegistryFingerprints()); + if (!serialized.isEmpty()) reference += ASYNC_PROGRESS_DELIMITER + serialized; + else if (options.getCompletedAsyncInjections() > 0) { + reference += ASYNC_PROGRESS_DELIMITER + options.getCompletedAsyncInjections(); + } + } + return reference; + } + + private static QueuedReplay parseQueuedReplay(String storedReference) { + String occurrenceId = occurrenceId(storedReference); + String withoutOccurrence = stripAsyncOccurrenceMarker(storedReference); + int marker = withoutOccurrence.lastIndexOf(ASYNC_PROGRESS_DELIMITER); + if (marker < 0) return new QueuedReplay(withoutOccurrence, 0, new HashMap<>(), new HashMap<>(), false, + occurrenceId); + String value = withoutOccurrence.substring(marker + ASYNC_PROGRESS_DELIMITER.length()); + if (value.startsWith("v3-")) { + try { + HashMap progress = new HashMap<>(); + HashMap fingerprints = new HashMap<>(); + String decoded = new String(Base64.getUrlDecoder().decode(value.substring(3)), StandardCharsets.UTF_8); + for (String line : decoded.split("\\n")) { + String[] pair = line.split("\\t", 3); + if (pair.length != 3) throw new IllegalArgumentException("Malformed replay checkpoint"); + progress.put(new String(Base64.getUrlDecoder().decode(pair[0]), StandardCharsets.UTF_8), + Integer.parseInt(pair[1])); + fingerprints.put(new String(Base64.getUrlDecoder().decode(pair[0]), StandardCharsets.UTF_8), pair[2]); + } + return new QueuedReplay(withoutOccurrence.substring(0, marker), 0, progress, fingerprints, false, + occurrenceId); + } catch (IllegalArgumentException ignored) { + return new QueuedReplay(withoutOccurrence, 0, new HashMap<>(), new HashMap<>(), false, occurrenceId); + } + } + if (value.startsWith("v2-")) { + try { + HashMap progress = new HashMap<>(); + String decoded = new String(Base64.getUrlDecoder().decode(value.substring(3)), StandardCharsets.UTF_8); + for (String line : decoded.split("\\n")) { + String[] pair = line.split("\\t", 2); + if (pair.length == 2) progress.put(pair[0], Integer.parseInt(pair[1])); + } + return new QueuedReplay(withoutOccurrence.substring(0, marker), 0, progress, new HashMap<>(), true, + occurrenceId); + } catch (IllegalArgumentException ignored) { + return new QueuedReplay(withoutOccurrence, 0, new HashMap<>(), new HashMap<>(), false, occurrenceId); + } + } + try { + int progress = Integer.parseInt(value); + return progress > 0 ? new QueuedReplay(withoutOccurrence.substring(0, marker), progress, new HashMap<>(), new HashMap<>(), true, + occurrenceId) + : new QueuedReplay(withoutOccurrence.substring(0, marker), 0, new HashMap<>(), new HashMap<>(), false, + occurrenceId); + } catch (NumberFormatException ignored) { + // Preserve malformed legacy values as a normal reward reference instead + // of accidentally skipping an arbitrary portion of a reward chain. + return new QueuedReplay(withoutOccurrence, 0, new HashMap<>(), new HashMap<>(), false, occurrenceId); + } + } + + private static String occurrenceId(String storedReference) { + int marker = storedReference.indexOf(ASYNC_OCCURRENCE_DELIMITER); + if (marker < 0) return null; + int start = marker + ASYNC_OCCURRENCE_DELIMITER.length(); + int end = storedReference.indexOf('%', start); + String candidate = storedReference.substring(start, end < 0 ? storedReference.length() : end); + try { + return UUID.fromString(candidate).toString(); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private static String stripAsyncOccurrenceMarker(String storedReference) { + int marker = storedReference.indexOf(ASYNC_OCCURRENCE_DELIMITER); + if (marker < 0) return storedReference; + int start = marker + ASYNC_OCCURRENCE_DELIMITER.length(); + int end = storedReference.indexOf('%', start); + return storedReference.substring(0, marker) + (end < 0 ? "" : storedReference.substring(end)); + } + + private static String queuedReference(QueuedReplay replay) { + return replay.rewardReference + (replay.asyncReplayOccurrenceId == null ? "" + : ASYNC_OCCURRENCE_DELIMITER + replay.asyncReplayOccurrenceId); + } + + private static String withAsyncOccurrence(String rewardEntry, String occurrenceId) { + int placeholders = rewardEntry.indexOf("%placeholders%"); + String reference = placeholders < 0 ? rewardEntry : rewardEntry.substring(0, placeholders); + String suffix = placeholders < 0 ? "" : rewardEntry.substring(placeholders); + return stripAsyncOccurrenceMarker(reference) + ASYNC_OCCURRENCE_DELIMITER + occurrenceId + suffix; + } + + private String ensureOfflineRewardOccurrence(String rewardEntry) { + if (occurrenceId(rewardEntry) != null) return rewardEntry; + synchronized (plugin) { + ArrayList pending = getOfflineRewards(); + int index = pending.indexOf(rewardEntry); + if (index < 0) throw new IllegalStateException("Claimed offline reward disappeared before migration"); + String updated = withAsyncOccurrence(rewardEntry, UUID.randomUUID().toString()); + pending.set(index, updated); + setOfflineRewardsDurably(pending, updated); + ReplayClaims claims = replayClaims(); + int claimed = claims.offline.getOrDefault(rewardEntry, 0); + if (claimed <= 1) claims.offline.remove(rewardEntry); + else claims.offline.put(rewardEntry, claimed - 1); + claims.offline.put(updated, claims.offline.getOrDefault(updated, 0) + 1); + return updated; + } + } + + private String ensureTimedRewardOccurrence(String rewardEntry, long time) { + if (occurrenceId(stripTimedExecutionMarker(rewardEntry)) != null) return rewardEntry; + synchronized (plugin) { + HashMap pending = getTimedRewards(); + if (!Long.valueOf(time).equals(pending.get(rewardEntry))) { + throw new IllegalStateException("Claimed timed reward disappeared before migration"); + } + String updated = withAsyncOccurrence(rewardEntry, UUID.randomUUID().toString()); + pending.remove(rewardEntry); + pending.put(updated, time); + setTimedRewardsDurably(pending); + ReplayClaims claims = replayClaims(); + claims.timed.remove(rewardEntry); + claims.timed.add(updated); + return updated; + } + } + + private static String encodeAsyncReplayProgress(Map progress) { + if (progress.isEmpty()) return ""; + StringBuilder encoded = new StringBuilder(); + for (Entry entry : progress.entrySet()) { + if (entry.getValue() != null && entry.getValue() > 0) { + encoded.append(entry.getKey()).append('\t').append(entry.getValue()).append('\n'); + } + } + return encoded.length() == 0 ? "" : "v2-" + Base64.getUrlEncoder().withoutPadding() + .encodeToString(encoded.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static String encodeAsyncReplayProgress(Map progress, Map fingerprints) { + if (fingerprints.isEmpty()) return encodeAsyncReplayProgress(progress); + StringBuilder encoded = new StringBuilder(); + for (Entry entry : progress.entrySet()) { + String fingerprint = fingerprints.get(entry.getKey()); + if (entry.getValue() != null && entry.getValue() > 0 && fingerprint == null) { + return encodeAsyncReplayProgress(progress); + } + } + for (Entry entry : fingerprints.entrySet()) { + if (entry.getValue() == null) continue; + encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(entry.getKey().getBytes(StandardCharsets.UTF_8))).append('\t') + .append(progress.getOrDefault(entry.getKey(), 0)).append('\t').append(entry.getValue()).append('\n'); + } + return encoded.length() == 0 ? encodeAsyncReplayProgress(progress) : "v3-" + + Base64.getUrlEncoder().withoutPadding().encodeToString(encoded.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static String stripTimedExecutionMarker(String storedReference) { + int marker = storedReference.indexOf("%extime%"); + if (marker < 0) return stripAsyncRetryMarker(storedReference); + int valueStart = marker + "%extime%".length(); + int nextMarker = storedReference.indexOf('%', valueStart); + return stripAsyncRetryMarker(storedReference.substring(0, marker) + + (nextMarker < 0 ? "" : storedReference.substring(nextMarker))); + } + + private static String stripAsyncRetryMarker(String storedReference) { + int marker = storedReference.indexOf(ASYNC_RETRY_DELIMITER); + if (marker < 0) return storedReference; + int valueStart = marker + ASYNC_RETRY_DELIMITER.length(); + int nextMarker = storedReference.indexOf('%', valueStart); + return storedReference.substring(0, marker) + (nextMarker < 0 ? "" : storedReference.substring(nextMarker)); + } + + private static int asyncRetryCount(String rewardEntry) { + int marker = rewardEntry.indexOf(ASYNC_RETRY_DELIMITER); + if (marker < 0) return 0; + int valueStart = marker + ASYNC_RETRY_DELIMITER.length(); + int nextMarker = rewardEntry.indexOf('%', valueStart); + try { return Integer.parseInt(rewardEntry.substring(valueStart, nextMarker < 0 ? rewardEntry.length() : nextMarker)); } + catch (NumberFormatException ignored) { return 0; } + } + + private static String withAsyncRetryCount(String rewardEntry, int count) { + int placeholders = rewardEntry.indexOf("%placeholders%"); + String reference = placeholders < 0 ? rewardEntry : rewardEntry.substring(0, placeholders); + String suffix = placeholders < 0 ? "" : rewardEntry.substring(placeholders); + return stripAsyncRetryMarker(reference) + ASYNC_RETRY_DELIMITER + count + suffix; + } + + private static String withAsyncReplayProgress(String rewardEntry, Throwable failure) { + Reward.RewardReplayFailure replayFailure = replayFailure(failure); + int completed = completedAsyncInjections(failure); + String serializedProgress = replayFailure == null ? "" : encodeAsyncReplayProgress(replayFailure.getReplayProgress(), + replayFailure.getReplayRegistryFingerprints()); + if (completed <= 0 && serializedProgress.isEmpty()) return rewardEntry; + int placeholders = rewardEntry.indexOf("%placeholders%"); + String storedReference = placeholders < 0 ? rewardEntry : rewardEntry.substring(0, placeholders); + String suffix = placeholders < 0 ? "" : rewardEntry.substring(placeholders); + if (replayFailure != null) suffix = "%placeholders%" + ArrayUtils.makeString(replayFailure.getReplayPlaceholders()); + QueuedReplay queuedReplay = parseQueuedReplay(stripAsyncRetryMarker(storedReference)); + if (!serializedProgress.isEmpty()) return queuedReference(queuedReplay) + ASYNC_PROGRESS_DELIMITER + + serializedProgress + suffix; + return queuedReference(queuedReplay) + ASYNC_PROGRESS_DELIMITER + + Math.max(queuedReplay.completedAsyncInjections, completed) + suffix; + } + + private static String withAsyncReplayProgress(String rewardEntry, Reward.ReplayCheckpoint checkpoint) { + int marker = rewardEntry.indexOf("%placeholders%"); + String reference = marker < 0 ? rewardEntry : rewardEntry.substring(0, marker); + QueuedReplay queuedReplay = parseQueuedReplay(stripAsyncRetryMarker(reference)); + String serialized = encodeAsyncReplayProgress(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints()); + return queuedReference(queuedReplay) + (serialized.isEmpty() ? "" : ASYNC_PROGRESS_DELIMITER + serialized) + + "%placeholders%" + ArrayUtils.makeString(checkpoint.getPlaceholders()); + } + + private static int completedAsyncInjections(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof Reward.RewardReplayFailure) { + return ((Reward.RewardReplayFailure) current).getCompletedInjectionCount(); + } + current = current.getCause(); + } + return 0; + } + + private static Reward.RewardReplayFailure replayFailure(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof Reward.RewardReplayFailure) return (Reward.RewardReplayFailure) current; + } + return null; + } + + private static final class QueuedReplay { + private final String rewardReference; + private final int completedAsyncInjections; + private final Map asyncReplayProgress; + private final Map asyncReplayRegistryFingerprints; + private final boolean legacyAsyncReplayCheckpoint; + private final String asyncReplayOccurrenceId; + + private QueuedReplay(String rewardReference, int completedAsyncInjections, + Map asyncReplayProgress, Map asyncReplayRegistryFingerprints, + boolean legacyAsyncReplayCheckpoint, String asyncReplayOccurrenceId) { + this.rewardReference = rewardReference; + this.completedAsyncInjections = completedAsyncInjections; + this.asyncReplayProgress = asyncReplayProgress; + this.asyncReplayRegistryFingerprints = asyncReplayRegistryFingerprints; + this.legacyAsyncReplayCheckpoint = legacyAsyncReplayCheckpoint; + this.asyncReplayOccurrenceId = asyncReplayOccurrenceId; + } } /** @@ -270,10 +953,53 @@ private String queuedRewardReference(Reward reward) { * * @param name the reward name */ - public void addUnClaimedChoiceReward(String name) { - ArrayList choices = getUnClaimedChoices(); + public synchronized void addUnClaimedChoiceReward(String name) { + ArrayList choices = getStoredUnClaimedChoices(); choices.add(name); - setUnClaimedChoice(choices); + getData().setStringList("UnClaimedChoices", choices); + } + + /** + * Adds an unclaimed choice once for a stable reward occurrence. The encoded + * occurrence remains in the same durable list as the user-facing reward name, + * so a replay cannot append a duplicate after a crash. + * + * @param name reward name shown in the choice UI + * @param occurrenceId stable logical reward occurrence + */ + public synchronized void addUnClaimedChoiceReward(String name, String occurrenceId) { + if (occurrenceId == null || occurrenceId.isEmpty()) { + addUnClaimedChoiceReward(name); + return; + } + ArrayList stored = getStoredUnClaimedChoices(); + if (stored.stream().anyMatch(entry -> occurrenceId.equals(decodeChoiceOccurrence(entry)))) return; + String entry = encodeUnclaimedChoice(CHOICE_OCCURRENCE_PREFIX, name, occurrenceId); + stored.add(entry); + getData().setStringList("UnClaimedChoices", stored); + } + + /** + * Marks a choice occurrence safe to remove after its owning reward checkpoint + * has been persisted. If it was claimed before that checkpoint, its tombstone + * is retired now; otherwise a later claim can remove it immediately. + * + * @param occurrenceId stable choice side-effect occurrence + */ + public synchronized void checkpointUnClaimedChoiceReward(String occurrenceId) { + if (occurrenceId == null || occurrenceId.isEmpty()) return; + ArrayList stored = getStoredUnClaimedChoices(); + for (int index = 0; index < stored.size(); index++) { + String entry = stored.get(index); + if (!occurrenceId.equals(decodeChoiceOccurrence(entry))) continue; + if (entry.startsWith(CONSUMED_CHOICE_OCCURRENCE_PREFIX)) stored.remove(index); + else if (entry.startsWith(CHOICE_OCCURRENCE_PREFIX)) { + stored.set(index, CHECKPOINTED_CHOICE_OCCURRENCE_PREFIX + + entry.substring(CHOICE_OCCURRENCE_PREFIX.length())); + } else return; + getData().setStringList("UnClaimedChoices", stored); + return; + } } /** @@ -309,34 +1035,66 @@ public void cacheIfNeeded() { public void checkDelayedTimedRewards() { plugin.debug("Checking timed/delayed for " + getPlayerName()); HashMap timed = getTimedRewards(); - HashMap newTimed = new HashMap<>(); for (Entry entry : timed.entrySet()) { long time = entry.getValue(); if (time != 0) { Date timeDate = new Date(time); - if (new Date().after(timeDate)) { - String[] data = entry.getKey().split("%placeholders%", 2); - String rewardReference = data[0].split("%extime%", 2)[0]; + if (new Date().after(timeDate) && claimTimedReward(entry.getKey(), time)) { + String migratedEntry; + try { + migratedEntry = ensureTimedRewardOccurrence(entry.getKey(), time); + } catch (Throwable failure) { + restoreTimedReward(entry.getKey(), time, failure); + continue; + } + String[] data = migratedEntry.split("%placeholders%", 2); + QueuedReplay queuedReplay = parseQueuedReplay(stripTimedExecutionMarker(data[0])); + String rewardReference = queuedReplay.rewardReference; String placeholders = ""; if (data.length > 1) { placeholders = data[1]; } RewardOptions replayOptions = new RewardOptions().setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholders)); + replayOptions.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); + replayOptions.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); + replayOptions.setAsyncReplayRegistryFingerprints(queuedReplay.asyncReplayRegistryFingerprints); + replayOptions.setLegacyAsyncReplayCheckpoint(queuedReplay.legacyAsyncReplayCheckpoint); + replayOptions.setAsyncReplayOccurrenceId(queuedReplay.asyncReplayOccurrenceId); + replayOptions.setTimedQueueReplay(true); replayOptions.addPlaceholder("date", "" + new SimpleDateFormat("EEE, d MMM yyyy HH:mm").format(new Date(time))); - plugin.getRewardHandler().givePersistedQueueReward(this, new PersistedQueueReference(rewardReference), replayOptions); + // Keep the due entry durable while asynchronous stages are running. A + // checkpoint can change its serialized key (for example by adding the + // v2 progress marker), so completion and failure must follow this + // reference rather than the original entry key. + AtomicReference currentEntry = new AtomicReference<>(migratedEntry); + replayOptions.setAsyncReplayCheckpointConsumer( + checkpoint -> checkpointTimedReward(currentEntry, time, checkpoint)); + enqueuePersistedReplay(() -> { + CompletionStage replay; + try { + replay = plugin.getRewardHandler().givePersistedQueueRewardAsync(this, + new PersistedQueueReference(rewardReference), replayOptions); + if (replay == null) throw new IllegalStateException("Timed reward replay returned no completion stage"); + } catch (Throwable failure) { + restoreTimedReward(currentEntry.get(), time, failure); + return CompletableFuture.completedFuture(null); + } + return replay.handle((ignored, failure) -> { + if (failure == null) completeTimedReward(currentEntry.get(), time); + else restoreTimedReward(currentEntry.get(), time, failure); + return null; + }); + }); String rewardName = rewardReference; plugin.debug("Giving timed/delayed reward " + rewardName + " for " + getPlayerName() + " with placeholders " + ArrayUtils.fromString(placeholders)); - } else { - newTimed.put(entry.getKey(), time); } } } - setTimedRewards(newTimed); } /** @@ -350,30 +1108,285 @@ public void checkOfflineRewards() { if (isCheckWorld()) { setCheckWorld(false); } - ArrayList rewards = getOfflineRewards(); - if (rewards.isEmpty()) { - return; - } - - setOfflineRewards(new ArrayList<>()); - RewardHandler rewardHandler = plugin.getRewardHandler(); - AdvancedCoreUser user = this; + dispatchOfflineRewards(false); + } + private void dispatchOfflineRewards(boolean force) { + ArrayList rewards = new ArrayList<>(getOfflineRewards()); for (String rewardEntry : rewards) { if (rewardEntry == null || rewardEntry.equals("null")) { continue; } + if (!claimOfflineReward(rewardEntry)) continue; + try { + rewardEntry = ensureOfflineRewardOccurrence(rewardEntry); + } catch (Throwable failure) { + restoreOfflineReward(rewardEntry, failure); + continue; + } String[] parts = rewardEntry.split("%placeholders%", 2); - String rewardReference = parts[0]; + QueuedReplay queuedReplay = parseQueuedReplay(parts[0]); + String rewardReference = queuedReplay.rewardReference; String placeholderStr = parts.length > 1 ? parts[1] : ""; RewardOptions options = new RewardOptions().setOnline(false).setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholderStr)); + if (force) options.setGiveOffline(false).forceOffline(); + options.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); + options.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); + options.setAsyncReplayRegistryFingerprints(queuedReplay.asyncReplayRegistryFingerprints); + options.setLegacyAsyncReplayCheckpoint(queuedReplay.legacyAsyncReplayCheckpoint); + options.setAsyncReplayOccurrenceId(queuedReplay.asyncReplayOccurrenceId); + AtomicReference currentEntry = new AtomicReference<>(rewardEntry); + options.setAsyncReplayCheckpointConsumer(checkpoint -> checkpointOfflineReward(currentEntry, checkpoint)); + + enqueuePersistedReplay(() -> { + CompletionStage replay; + try { + replay = plugin.getRewardHandler().givePersistedQueueRewardAsync(this, + new PersistedQueueReference(rewardReference), options); + if (replay == null) throw new IllegalStateException("Offline reward replay returned no completion stage"); + } catch (Throwable failure) { + restoreOfflineReward(currentEntry.get(), failure); + return CompletableFuture.completedFuture(null); + } + return replay.handle((ignored, failure) -> { + if (failure == null) completeOfflineReward(currentEntry.get()); + else if (Reward.isOfflineReplayDeferred(failure)) releaseDeferredOfflineReward(currentEntry.get()); + else restoreOfflineReward(currentEntry.get(), failure); + return null; + }); + }); + } + } + + /** + * Runs persisted offline and timed occurrences one at a time for this plugin + * and user. The tail deliberately absorbs a completed occurrence's failure: + * its own restore path retains the queue entry, while the next occurrence must + * still be allowed to start without blocking a caller thread. + */ + private void enqueuePersistedReplay(Supplier> replay) { + ReplayClaims claims; + CompletableFuture previous; + CompletableFuture next = new CompletableFuture<>(); + synchronized (plugin) { + claims = replayClaims(); + previous = claims.serialReplayTail; + claims.serialReplayTail = next; + } + previous.whenComplete((ignored, previousFailure) -> { + CompletionStage stage; + try { + stage = replay.get(); + if (stage == null) throw new IllegalStateException("Persisted reward replay returned no completion stage"); + } catch (Throwable failure) { + next.complete(null); + releaseReplayClaimsIfEmpty(claims); + return; + } + stage.whenComplete((result, failure) -> { + next.complete(null); + releaseReplayClaimsIfEmpty(claims); + }); + }); + } + + private void checkpointOfflineReward(AtomicReference currentEntry, Reward.ReplayCheckpoint checkpoint) { + synchronized (plugin) { + ReplayClaims claims = replayClaims(); + String current = currentEntry.get(); + String updated = withAsyncReplayProgress(current, checkpoint); + ArrayList pending = getOfflineRewards(); + int index = pending.indexOf(current); + if (index >= 0) { + pending.set(index, updated); + setOfflineRewardsDurably(pending, updated); + int claimed = claims.offline.getOrDefault(current, 0); + // Migrate this occurrence only. Identical legacy queue entries share a + // serialized key, but may each already be executing; moving the whole + // count would make the remaining old entry appear unclaimed. + if (claimed <= 1) claims.offline.remove(current); + else claims.offline.put(current, claimed - 1); + claims.offline.put(updated, claims.offline.getOrDefault(updated, 0) + 1); + currentEntry.set(updated); + } + } + } + + private boolean claimOfflineReward(String rewardEntry) { + synchronized (plugin) { + ReplayClaims claims = replayClaims(); + String occurrence = occurrenceId(rewardEntry); + if (occurrence != null) { + boolean claimed = claims.offline.entrySet().stream().anyMatch(entry -> entry.getValue() > 0 + && occurrence.equals(occurrenceId(entry.getKey()))); + if (claimed) return false; + claims.offline.put(rewardEntry, 1); + return true; + } + int occurrences = 0; + for (String pending : getOfflineRewards()) if (rewardEntry.equals(pending)) occurrences++; + int claimed = claims.offline.getOrDefault(rewardEntry, 0); + if (claimed >= occurrences) return false; + claims.offline.put(rewardEntry, claimed + 1); + return true; + } + } - rewardHandler.givePersistedQueueReward(user, new PersistedQueueReference(rewardReference), options); + private void completeOfflineReward(String rewardEntry) { + synchronized (plugin) { + ArrayList pending = getOfflineRewards(); + pending.remove(rewardEntry); + setOfflineRewards(pending); + releaseOfflineReward(rewardEntry); + } + } + + private void releaseDeferredOfflineReward(String rewardEntry) { + synchronized (plugin) { + releaseOfflineReward(rewardEntry); + } + } + + /** Retains and updates a queue entry only after an asynchronous replay fails. */ + private void restoreOfflineReward(String rewardEntry, Throwable failure) { + // Match addOfflineRewards' lock so a newly queued reward cannot be lost + // while a failed replay is being restored. + synchronized (plugin) { + ArrayList pending = getOfflineRewards(); + int index = pending.indexOf(rewardEntry); + try { + if (index >= 0) { + String restored = withAsyncReplayProgress(rewardEntry, failure); + pending.set(index, restored); + setOfflineRewards(pending, true, restored); + } else { + setOfflineRewards(pending); + } + } finally { + releaseOfflineReward(rewardEntry); + } } + plugin.getLogger().warning("Could not deliver queued offline reward for " + getPlayerName() + + "; it will be retried: " + failure.getMessage()); + } + private void releaseOfflineReward(String rewardEntry) { + ReplayClaims claims = replayClaims(); + int claimed = claims.offline.getOrDefault(rewardEntry, 0); + if (claimed <= 1) claims.offline.remove(rewardEntry); + else claims.offline.put(rewardEntry, claimed - 1); + releaseReplayClaimsIfEmpty(claims); + } + + private boolean claimTimedReward(String rewardEntry, long time) { + synchronized (plugin) { + ReplayClaims claims = replayClaims(); + if (claims.timed.contains(rewardEntry) || !Long.valueOf(time).equals(getTimedRewards().get(rewardEntry))) { + return false; + } + claims.timed.add(rewardEntry); + return true; + } + } + + private void completeTimedReward(String rewardEntry, long time) { + synchronized (plugin) { + ReplayClaims claims = replayClaims(); + HashMap pending = getTimedRewards(); + if (Long.valueOf(time).equals(pending.get(rewardEntry))) { + pending.remove(rewardEntry); + setTimedRewards(pending); + } + claims.timed.remove(rewardEntry); + releaseReplayClaimsIfEmpty(claims); + } + } + + /** + * Persists every completed asynchronous stage before the next one can run. + * Timed reward keys are map keys, so migrate the exact current key while + * retaining its execution time and its in-flight claim. + */ + private void checkpointTimedReward(AtomicReference currentEntry, long time, + Reward.ReplayCheckpoint checkpoint) { + synchronized (plugin) { + ReplayClaims claims = replayClaims(); + String current = currentEntry.get(); + String updated = withAsyncReplayProgress(current, checkpoint); + if (current.equals(updated)) return; + HashMap pending = getTimedRewards(); + if (!Long.valueOf(time).equals(pending.get(current))) return; + pending.remove(current); + pending.put(updated, time); + setTimedRewardsDurably(pending); + claims.timed.remove(current); + claims.timed.add(updated); + currentEntry.set(updated); + } + } + + /** Restores a due timed entry after an asynchronous replay fails. */ + private void restoreTimedReward(String rewardEntry, long time, Throwable failure) { + long retryTime; + synchronized (plugin) { + ReplayClaims claims = replayClaims(); + HashMap pending = getTimedRewards(); + String occurrence = occurrenceId(stripTimedExecutionMarker(rewardEntry)); + String restoredEntry = rewardEntry; + if (occurrence != null) { + for (String candidate : pending.keySet()) { + if (occurrence.equals(occurrenceId(stripTimedExecutionMarker(candidate)))) { + restoredEntry = candidate; + if (!candidate.equals(rewardEntry)) break; + } + } + pending.keySet().removeIf(candidate -> occurrence.equals( + occurrenceId(stripTimedExecutionMarker(candidate)))); + claims.timed.removeIf(candidate -> occurrence.equals( + occurrenceId(stripTimedExecutionMarker(candidate)))); + } else { + pending.remove(rewardEntry); + claims.timed.remove(rewardEntry); + } + int retry = Math.min(8, asyncRetryCount(restoredEntry) + 1); + long retryDelay = Math.min(TimeUnit.MINUTES.toMillis(5), TimeUnit.SECONDS.toMillis(1L << retry)); + retryTime = System.currentTimeMillis() + retryDelay; + pending.put(withAsyncRetryCount(withAsyncReplayProgress(restoredEntry, failure), retry), retryTime); + setTimedRewards(pending); + releaseReplayClaimsIfEmpty(claims); + } + // A due entry restored after its original timer fired needs its own retry + // timer; waiting for reconnect would strand it indefinitely. + loadTimedDelayedTimer(retryTime); + plugin.getLogger().warning("Could not deliver queued timed reward for " + getPlayerName() + + "; it will be retried: " + failure.getMessage()); + } + + private ReplayClaims replayClaims() { + synchronized (REPLAY_CLAIMS_LOCK) { + return REPLAY_CLAIMS.computeIfAbsent(plugin, ignored -> new HashMap<>()) + .computeIfAbsent(getUUID(), ignored -> new ReplayClaims()); + } + } + + private void releaseReplayClaimsIfEmpty(ReplayClaims claims) { + synchronized (plugin) { + synchronized (REPLAY_CLAIMS_LOCK) { + // Claim mutations use the same lock ordering (plugin, then this + // lock). Recheck only while holding both locks; checking before + // entering them permits a concurrent wrapper to add a claim that + // cleanup then drops. + if (!claims.offline.isEmpty() || !claims.timed.isEmpty() + || !claims.serialReplayTail.isDone()) return; + HashMap byUser = REPLAY_CLAIMS.get(plugin); + if (byUser == null || byUser.get(getUUID()) != claims) return; + byUser.remove(getUUID()); + if (byUser.isEmpty()) REPLAY_CLAIMS.remove(plugin); + } + } } /** @@ -431,29 +1444,7 @@ public void forceRunOfflineRewards() { } setCheckWorld(false); - ArrayList rewards = getOfflineRewards(); - if (rewards.isEmpty()) { - return; - } - - setOfflineRewards(new ArrayList<>()); - RewardHandler rewardHandler = plugin.getRewardHandler(); - AdvancedCoreUser user = this; - - for (String rewardEntry : rewards) { - if (rewardEntry == null || rewardEntry.equals("null")) { - continue; - } - - String[] parts = rewardEntry.split("%placeholders%", 2); - String rewardReference = parts[0]; - String placeholderStr = parts.length > 1 ? parts[1] : ""; - - RewardOptions options = new RewardOptions().setOnline(false).setGiveOffline(false).forceOffline() - .setCheckTimed(false).withPlaceHolder(ArrayUtils.fromString(placeholderStr)); - - rewardHandler.givePersistedQueueReward(user, new PersistedQueueReference(rewardReference), options); - } + dispatchOfflineRewards(true); } /** @@ -670,8 +1661,12 @@ public HashMap getTimedRewards() { * * @return the unclaimed choices */ - public ArrayList getUnClaimedChoices() { - return getData().getStringList("UnClaimedChoices", userDataFetchMode); + public synchronized ArrayList getUnClaimedChoices() { + ArrayList choices = new ArrayList<>(); + for (String stored : getStoredUnClaimedChoices()) { + if (!stored.startsWith(CONSUMED_CHOICE_OCCURRENCE_PREFIX)) choices.add(decodeUnclaimedChoice(stored)); + } + return choices; } /** @@ -702,6 +1697,7 @@ public String getUUID() { */ public void giveExp(int exp) { Player player = getPlayer(); + if (scheduleOwnedPlayerAction(player, () -> player.giveExp(exp), "exp:" + exp)) return; if (player != null) { player.giveExp(exp); } @@ -714,6 +1710,7 @@ public void giveExp(int exp) { */ public void giveExpLevels(int num) { Player p = getPlayer(); + if (scheduleOwnedPlayerAction(p, () -> p.setLevel(p.getLevel() + num), "exp-levels:" + num)) return; if (p != null) { p.setLevel(p.getLevel() + num); } @@ -725,7 +1722,12 @@ public void giveExpLevels(int num) { * @param builder the item builder */ public void giveItem(ItemBuilder builder) { - giveItem(builder.toItemStack(getPlayer())); + Player player = getPlayer(); + if (player == null && hasOwnedAsyncActionCollection()) { + collectAsyncFailure(new IllegalStateException("Player became unavailable before item reward delivery")); + return; + } + giveItem(builder.toItemStack(player)); } /** @@ -741,15 +1743,9 @@ public void giveItem(ItemStack item) { final Player player = getPlayer(); if (plugin.isEnabled()) { - getPlugin().getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - if (player != null) { - plugin.getFullInventoryHandler().giveItem(player, item); - } - } - }, player); + scheduleLegacyItemAction(player, item); + } else { + collectAsyncFailure(new IllegalStateException("Plugin disabled before item reward was scheduled")); } } @@ -761,7 +1757,7 @@ public void run() { * @param placeholders the placeholders */ public void giveItem(ItemStack itemStack, HashMap placeholders) { - giveItem(new ItemBuilder(itemStack).setPlaceholders(placeholders).toItemStack(getPlayer())); + giveItem(new ItemBuilder(itemStack).setPlaceholders(placeholders)); } /** @@ -777,15 +1773,9 @@ public void giveItems(ItemStack... item) { final Player player = getPlayer(); if (plugin.isEnabled()) { - getPlugin().getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - if (player != null) { - plugin.getFullInventoryHandler().giveItem(player, item); - } - } - }, player); + scheduleLegacyItemAction(player, item); + } else { + collectAsyncFailure(new IllegalStateException("Plugin disabled before item reward was scheduled")); } } @@ -797,36 +1787,30 @@ public void run() { */ public void giveMoney(double m) { if (!plugin.isEnabled()) { + collectAsyncFailure(new IllegalStateException("Plugin disabled before money reward was scheduled")); return; } if (plugin.getVaultHandler() != null && plugin.getVaultHandler().getEcon() != null) { try { if (m > 0) { final double money = m; - getPlugin().getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - plugin.getVaultHandler().getEcon().depositPlayer(getOfflinePlayer(), money); - } - }); + scheduleLegacyRewardAction( + () -> plugin.getVaultHandler().getEcon().depositPlayer(getOfflinePlayer(), money), null, false, + "money:deposit:" + Double.toString(money)); } else if (m < 0) { m = m * -1; final double money = m; - getPlugin().getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - plugin.getVaultHandler().getEcon().withdrawPlayer(getOfflinePlayer(), money); - } - }); + scheduleLegacyRewardAction( + () -> plugin.getVaultHandler().getEcon().withdrawPlayer(getOfflinePlayer(), money), null, false, + "money:withdraw:" + Double.toString(money)); } } catch ( IllegalStateException e) { e.printStackTrace(); + collectAsyncFailure(e); } } } @@ -849,17 +1833,204 @@ public void giveMoney(int money) { */ public void givePotionEffect(String potionName, int duration, int amplifier) { Player player = getPlayer(); - if (player != null && plugin.isEnabled()) { - getPlugin().getBukkitScheduler().runTask(plugin, new Runnable() { + if (player == null) { + if (hasOwnedAsyncActionCollection()) collectAsyncFailure(new IllegalStateException( + "Player became unavailable before potion reward delivery")); + return; + } + if (!plugin.isEnabled()) { + if (hasOwnedAsyncActionCollection()) collectAsyncFailure(new IllegalStateException( + "Potion reward could not be scheduled because the plugin is unavailable")); + return; + } + scheduleLegacyRewardAction(() -> player.addPotionEffect( + new PotionEffect(PotionEffectType.getByName(potionName), 20 * duration, amplifier)), player, true, + "potion:" + potionName + ":" + duration + ":" + amplifier); + } - @SuppressWarnings("deprecation") - @Override - public void run() { - player.addPotionEffect( - new PotionEffect(PotionEffectType.getByName(potionName), 20 * duration, amplifier)); + /** Queues a legacy Bukkit action while exposing a nonblocking completion internally. */ + private void scheduleLegacyItemAction(Player player, ItemStack... item) { + StringBuilder descriptor = new StringBuilder("item"); + for (ItemStack current : item) { + descriptor.append('\n').append(itemDescriptor(current)); + } + if (collectAsyncAction(() -> player == null ? CompletableFuture.failedFuture( + replayActionNotStarted("Player became unavailable before item reward delivery")) + : plugin.getFullInventoryHandler().giveItemAsync(player, item), descriptor.toString())) { + return; + } + // Preserve ordinary fire-and-forget behavior without adding an ignored async + // timeout or a second scheduler boundary. Replay scopes above await the + // inventory handler's actual owner-task completion directly. + plugin.getFullInventoryHandler().giveItem(player, item); + } + + private static String itemDescriptor(ItemStack item) { + if (item == null) return "null"; + try { + return canonicalActionDescriptor(item.serialize()); + } catch (Throwable ignored) { + // Unit-test and early-bootstrap environments can lack Bukkit's unsafe + // serializer. Retain every independently accessible item property instead + // of collapsing different metadata to an unstable display string. + HashMap fallback = new HashMap<>(); + try { + fallback.put("type", item.getType()); + } catch (Throwable ignoredAgain) { } + try { + fallback.put("amount", item.getAmount()); + } catch (Throwable ignoredAgain) { } + try { + fallback.put("durability", item.getDurability()); + } catch (Throwable ignoredAgain) { } + try { + fallback.put("meta", item.getItemMeta()); + } catch (Throwable ignoredAgain) { } + fallback.put("class", item.getClass().getName()); + return canonicalActionDescriptor(fallback); + } + } + + /** Deterministic, delimiter-safe encoding for replayed action payloads. */ + private static String canonicalActionDescriptor(Object value) { + if (value == null) return encodedActionPart("null", ""); + if (value instanceof CharSequence || value instanceof Character || value instanceof Boolean + || value instanceof Number || value instanceof Enum) { + return encodedActionPart(value.getClass().getName(), value instanceof Enum + ? ((Enum) value).name() : String.valueOf(value)); + } + if (value instanceof ConfigurationSerializable) { + ConfigurationSerializable serializable = (ConfigurationSerializable) value; + return encodedActionPart("serializable-class", value.getClass().getName()) + + canonicalActionDescriptor(serializable.serialize()); + } + if (value instanceof Map) { + ArrayList entries = new ArrayList<>(); + for (Entry entry : ((Map) value).entrySet()) { + entries.add(canonicalActionDescriptor(entry.getKey()) + canonicalActionDescriptor(entry.getValue())); + } + java.util.Collections.sort(entries); + return encodedActionParts("map", entries); + } + if (value instanceof Iterable) { + ArrayList entries = new ArrayList<>(); + for (Object entry : (Iterable) value) entries.add(canonicalActionDescriptor(entry)); + return encodedActionParts("list", entries); + } + if (value.getClass().isArray()) { + ArrayList entries = new ArrayList<>(); + for (int index = 0; index < Array.getLength(value); index++) { + entries.add(canonicalActionDescriptor(Array.get(value, index))); + } + return encodedActionParts("array:" + value.getClass().getComponentType().getName(), entries); + } + return encodedActionPart("object-class", value.getClass().getName()); + } + + private static String encodedActionParts(String type, Iterable values) { + StringBuilder encoded = new StringBuilder(encodedActionPart("collection", type)); + for (String value : values) encoded.append(encodedActionPart("entry", value)); + return encoded.toString(); + } + + private static String encodedActionPart(String type, String value) { + String encodedType = Base64.getUrlEncoder().withoutPadding().encodeToString(type.getBytes(StandardCharsets.UTF_8)); + String encodedValue = Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + return encodedType.length() + ":" + encodedType + encodedValue.length() + ":" + encodedValue; + } + + /** Queues a legacy Bukkit action while exposing a nonblocking completion internally. */ + private void scheduleLegacyRewardAction(Runnable action, Player player, boolean playerAware, String descriptor) { + scheduleLegacyRewardActionAsync(() -> { + action.run(); + return CompletableFuture.completedFuture(null); + }, player, playerAware, descriptor); + } + + /** + * Makes a direct player mutation durable only while this user's replay scope is + * active. Outside that scope callers retain the legacy immediate behavior. + */ + private boolean scheduleOwnedPlayerAction(Player player, Runnable action, String descriptor) { + if (!hasOwnedAsyncActionCollection()) return false; + if (player == null) { + collectAsyncFailure(new IllegalStateException("Player became unavailable before reward delivery")); + return true; + } + if (!plugin.isEnabled()) { + collectAsyncFailure(new IllegalStateException("Plugin disabled before player reward delivery")); + return true; + } + scheduleLegacyRewardAction(action, player, true, descriptor); + return true; + } + + private void scheduleLegacyRewardActionAsync(Supplier> action, Player player, + boolean playerAware, String descriptor) { + if (!collectAsyncAction(() -> { + CompletableFuture completion = new CompletableFuture<>(); + AtomicBoolean claimed = new AtomicBoolean(); + Runnable dispatch = () -> { + if (!claimed.compareAndSet(false, true)) return; + if (!plugin.isEnabled()) { + completion.completeExceptionally(new LegacyActionNotStartedException( + "Plugin disabled before scheduled reward action ran")); + return; } - }, player); + try { + if (playerAware) validateLiveScheduledPlayer(player); + CompletionStage stage = action.get(); + if (stage == null) { + completion.completeExceptionally(new IllegalStateException("Scheduled reward action returned null")); + return; + } + stage.whenComplete((ignored, failure) -> { + if (failure == null) completion.complete(null); + else completion.completeExceptionally(failure); + }); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }; + try { + if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); + else getPlugin().getBukkitScheduler().runTask(plugin, dispatch); + } catch (Throwable failure) { + claimed.set(true); + completion.completeExceptionally(new LegacyActionNotStartedException( + "Scheduler rejected reward action before dispatch", failure)); + } + CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS).execute(() -> { + if (claimed.compareAndSet(false, true)) { + completion.completeExceptionally(new LegacyActionNotStartedException( + "Timed out waiting for scheduled reward action", new TimeoutException())); + } + }); + return completion; + }, descriptor)) { + Runnable dispatch = () -> { + try { + CompletionStage stage = action.get(); + if (stage == null) throw new IllegalStateException("Scheduled reward action returned null"); + } catch (Throwable failure) { + if (failure instanceof RuntimeException) throw (RuntimeException) failure; + if (failure instanceof Error) throw (Error) failure; + throw new IllegalStateException("Failed to schedule legacy reward action", failure); + } + }; + if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); + else getPlugin().getBukkitScheduler().runTask(plugin, dispatch); + } + } + /** Runs on the player-owned scheduler before a replay-aware player mutation. */ + private void validateLiveScheduledPlayer(Player player) { + if (player == null || player.getUniqueId() == null) { + throw replayActionNotStarted("Scheduled player reward has no live player identity"); + } + Player current = Bukkit.getPlayer(player.getUniqueId()); + if (current != player || !current.isOnline()) { + throw replayActionNotStarted("Player became unavailable before scheduled reward delivery"); } } @@ -1238,6 +2409,65 @@ public void run() { } } + /** + * Completion-aware player-command dispatch used by durable asynchronous reward + * replay. It completes only after each queued player command has run. + */ + public CompletionStage preformCommandAsync(ArrayList commands, HashMap placeholders) { + try { + ArrayList templates = commands == null ? new ArrayList<>() : new ArrayList<>(commands); + final ArrayList cmds = PlaceholderUtils.replaceJavascript(getPlayer(), + PlaceholderUtils.replacePlaceHolder(templates, placeholders)); + return Reward.replayCommandSequence(plugin, placeholders, "player", templates, cmds, (command, ignoredIndex) -> { + final Player player = getPlayer(); + if (player == null || !plugin.isEnabled()) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Player command could not run because the player or plugin is unavailable")); + } + plugin.debug("Executing player command for " + getPlayerName() + ": " + command); + return runPlayerCommandAsync(player, command); + }); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + + /** Fails before mixed reward sections run console commands for an unavailable player. */ + public CompletionStage validatePlayerCommandAvailabilityAsync() { + return getPlayer() == null || !plugin.isEnabled() + ? CompletableFuture.failedFuture( + new IllegalStateException("Player command could not run because the player or plugin is unavailable")) + : CompletableFuture.completedFuture(null); + } + + private CompletableFuture runPlayerCommandAsync(Player player, String command) { + CompletableFuture completion = new CompletableFuture<>(); + AtomicBoolean claimed = new AtomicBoolean(); + Runnable dispatch = () -> { + if (!claimed.compareAndSet(false, true)) return; + try { + validateLiveScheduledPlayer(player); + player.chat("/" + command); + completion.complete(null); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }; + try { + getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); + } catch (Throwable failure) { + claimed.set(true); + completion.completeExceptionally(failure); + return completion; + } + CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS).execute(() -> { + if (claimed.compareAndSet(false, true)) { + completion.completeExceptionally(new TimeoutException("Timed out waiting for scheduled player command")); + } + }); + return completion; + } + /** * Performs a command as the player with placeholders. * @@ -1286,10 +2516,20 @@ public void removePermission(String permission) { * * @param name the reward name */ - public void removeUnClaimedChoiceReward(String name) { - ArrayList choices = getUnClaimedChoices(); - choices.remove(name); - setUnClaimedChoice(choices); + public synchronized void removeUnClaimedChoiceReward(String name) { + ArrayList choices = getStoredUnClaimedChoices(); + for (int index = 0; index < choices.size(); index++) { + String entry = choices.get(index); + if (entry.startsWith(CONSUMED_CHOICE_OCCURRENCE_PREFIX)) continue; + if (java.util.Objects.equals(name, decodeUnclaimedChoice(entry))) { + if (entry.startsWith(CHOICE_OCCURRENCE_PREFIX)) { + choices.set(index, CONSUMED_CHOICE_OCCURRENCE_PREFIX + + entry.substring(CHOICE_OCCURRENCE_PREFIX.length())); + } else choices.remove(index); + break; + } + } + getData().setStringList("UnClaimedChoices", choices); } /** @@ -1560,17 +2800,77 @@ public void setLastOnline(long online) { * @param offlineRewards the offline rewards */ public void setOfflineRewards(ArrayList offlineRewards) { - // MySQL TEXT max length is 65535 bytes - int maxLength = 65535; - String str = String.join("%line%", offlineRewards); + setOfflineRewards(offlineRewards, true); + } + + /** Writes a checkpoint without allowing size trimming to discard its active entry. */ + private void setOfflineRewardsDurably(ArrayList offlineRewards, String protectedEntry) { + persistReplayCheckpoint(() -> setOfflineRewards(offlineRewards, false, protectedEntry)); + } + + private void setOfflineRewards(ArrayList offlineRewards, boolean queue) { + setOfflineRewards(offlineRewards, queue, null); + } + + private void setOfflineRewards(ArrayList offlineRewards, boolean queue, String protectedEntry) { + synchronized (plugin) { + // MySQL TEXT max length is 65535 bytes + int maxLength = 65535; + String str = String.join("%line%", offlineRewards); + ReplayClaims claims = existingReplayClaims(); + HashMap removableLegacyOccurrences = new HashMap<>(); + HashSet claimedOccurrenceIds = new HashSet<>(); + for (String rewardEntry : offlineRewards) { + if (occurrenceId(rewardEntry) == null) { + removableLegacyOccurrences.merge(rewardEntry, 1, Integer::sum); + } + } + if (claims != null) { + for (Entry claim : claims.offline.entrySet()) { + if (claim.getValue() == null || claim.getValue() <= 0) continue; + String occurrence = occurrenceId(claim.getKey()); + if (occurrence != null) claimedOccurrenceIds.add(occurrence); + else removableLegacyOccurrences.computeIfPresent(claim.getKey(), + (ignored, count) -> Math.max(0, count - claim.getValue())); + } + } - // Remove oldest rewards until within limit - while (str.getBytes().length > maxLength && !offlineRewards.isEmpty()) { - offlineRewards.remove(0); - str = String.join("%line%", offlineRewards); + // Remove oldest unclaimed rewards until within limit. Claimed entries may + // be waiting in the serial replay backlog and remain the only durable copy. + while (str.getBytes().length > maxLength && !offlineRewards.isEmpty()) { + int removalIndex = 0; + while (removalIndex < offlineRewards.size()) { + String candidate = offlineRewards.get(removalIndex); + String occurrence = occurrenceId(candidate); + boolean explicitlyProtected = protectedEntry != null && protectedEntry.equals(candidate); + boolean claimed = occurrence != null ? claimedOccurrenceIds.contains(occurrence) + : removableLegacyOccurrences.getOrDefault(candidate, 0) <= 0; + if (!explicitlyProtected && !claimed) break; + removalIndex++; + } + if (removalIndex >= offlineRewards.size()) { + // Never trade a durable in-flight checkpoint for queue-size compliance. + break; + } + String removed = offlineRewards.remove(removalIndex); + if (occurrenceId(removed) == null) { + removableLegacyOccurrences.computeIfPresent(removed, + (ignored, count) -> Math.max(0, count - 1)); + } + str = String.join("%line%", offlineRewards); + } + + if (queue) data.setStringList(plugin.getUserManager().getOfflineRewardsPath(), offlineRewards); + else data.setStringList(plugin.getUserManager().getOfflineRewardsPath(), offlineRewards, false); } + } - data.setStringList(plugin.getUserManager().getOfflineRewardsPath(), offlineRewards); + /** Returns shared replay state without creating an empty entry for ordinary writes. */ + private ReplayClaims existingReplayClaims() { + synchronized (REPLAY_CLAIMS_LOCK) { + HashMap byUser = REPLAY_CLAIMS.get(plugin); + return byUser == null ? null : byUser.get(getUUID()); + } } /** @@ -1598,6 +2898,27 @@ public void setRepeatAmount(Reward reward, int amount) { * @param timed the timed rewards */ public void setTimedRewards(HashMap timed) { + synchronized (plugin) { + setTimedRewards(timed, true); + } + } + + /** Writes an async replay checkpoint before the next stage can execute. */ + private void setTimedRewardsDurably(HashMap timed) { + persistReplayCheckpoint(() -> setTimedRewards(timed, false)); + } + + /** + * A cached user may already have an older queued value for this key. Drain it + * before the direct write so the delayed cache flush cannot replace a replay + * checkpoint that an asynchronous reward stage has already relied on. + */ + private void persistReplayCheckpoint(Runnable write) { + if (isCached()) getCache().flushChangesAndRun(write); + else write.run(); + } + + private void setTimedRewards(HashMap timed, boolean queue) { ArrayList timedRewards = new ArrayList<>(); for (Entry entry : timed.entrySet()) { @@ -1607,7 +2928,8 @@ public void setTimedRewards(HashMap timed) { timedRewards.add(str); } - data.setStringList("TimedRewards", timedRewards); + if (queue) data.setStringList("TimedRewards", timedRewards); + else data.setStringList("TimedRewards", timedRewards, false); } /** @@ -1619,6 +2941,54 @@ public void setUnClaimedChoice(ArrayList rewards) { getData().setStringList("UnClaimedChoices", rewards); } + private ArrayList getStoredUnClaimedChoices() { + return getData().getStringList("UnClaimedChoices", userDataFetchMode); + } + + private static String encodeUnclaimedChoice(String prefix, String name, String occurrenceId) { + Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + return prefix + + encoder.encodeToString(occurrenceId.getBytes(StandardCharsets.UTF_8)) + "." + + encoder.encodeToString((name == null ? "" : name).getBytes(StandardCharsets.UTF_8)); + } + + private static String decodeUnclaimedChoice(String stored) { + String encoded = encodedChoice(stored); + if (encoded == null) return stored; + int separator = encoded.indexOf('.'); + if (separator < 1) return stored; + try { + Base64.getUrlDecoder().decode(encoded.substring(0, separator)); + return new String(Base64.getUrlDecoder().decode(encoded.substring(separator + 1)), StandardCharsets.UTF_8); + } catch (IllegalArgumentException failure) { + return stored; + } + } + + private static String decodeChoiceOccurrence(String stored) { + String encoded = encodedChoice(stored); + if (encoded == null) return null; + int separator = encoded.indexOf('.'); + if (separator < 1) return null; + try { + return new String(Base64.getUrlDecoder().decode(encoded.substring(0, separator)), StandardCharsets.UTF_8); + } catch (IllegalArgumentException failure) { + return null; + } + } + + private static String encodedChoice(String stored) { + if (stored == null) return null; + if (stored.startsWith(CHOICE_OCCURRENCE_PREFIX)) return stored.substring(CHOICE_OCCURRENCE_PREFIX.length()); + if (stored.startsWith(CHECKPOINTED_CHOICE_OCCURRENCE_PREFIX)) { + return stored.substring(CHECKPOINTED_CHOICE_OCCURRENCE_PREFIX.length()); + } + if (stored.startsWith(CONSUMED_CHOICE_OCCURRENCE_PREFIX)) { + return stored.substring(CONSUMED_CHOICE_OCCURRENCE_PREFIX.length()); + } + return null; + } + /** * Sets the user to not cache data. */ @@ -1669,4 +3039,4 @@ public void updateTempCacheWithColumns(ArrayList cols) { getUserData().updateTempCacheWithColumns(cols); } -} \ No newline at end of file +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java index b2b60078bd..c58b17fc9d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java @@ -100,6 +100,31 @@ public synchronized void clearChanges() { } } + /** + * Drains every queued or in-flight cache batch before running a synchronous + * write. The action runs while holding the cache monitor, preventing an old + * queued value from being persisted after a durable replacement. + */ + public void flushChangesAndRun(Runnable action) { + if (action == null) return; + while (true) { + processChanges(); + synchronized (this) { + while (inFlightBatches > 0) { + try { + wait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while flushing cached user changes", e); + } + } + if (cachedChanges != null && !cachedChanges.isEmpty()) continue; + action.run(); + return; + } + } + } + public void displayCache() { manager.getPlugin().devDebug(displayCacheStringList().toString()); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java index 5d54765d94..c5b160cb61 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java @@ -29,18 +29,16 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { @Override public List beforeExecutorShutdown() { return List.of( + new Cleanup("full inventory handler", () -> { + FullInventoryHandler handler = plugin.getFullInventoryHandler(); + if (handler != null) handler.shutdown(); + }), new Cleanup("Javascript engine", () -> { if (plugin.getOptions() != null && plugin.getOptions().isJavascriptEngineEnabled()) { plugin.getLogger().info("Shutting down Javascript engine"); JavascriptEngineHandler.getInstance().clearCachedEngine(); } }), - new Cleanup("MySQL", () -> { - if (plugin.isLoadUserData() && plugin.getOptions() != null - && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()) && plugin.getMysql() != null) { - plugin.getMysql().close(); - } - }), new Cleanup("server data timestamp", () -> { if (plugin.getServerDataFile() != null) plugin.getServerDataFile().setLastUpdated(); })); @@ -54,17 +52,21 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { @Override public List afterExecutorShutdown() { return List.of( + new Cleanup("MySQL", () -> { + if (plugin.isLoadUserData() && plugin.getOptions() != null + && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()) && plugin.getMysql() != null) { + ScheduledExecutorService timer = plugin.getTimer(); + if (timer != null && !timer.isTerminated()) { + plugin.getLogger().warning("Leaving MySQL open because reward checkpoint tasks did not terminate"); + return; + } + plugin.getMysql().close(); + } + }), new Cleanup("plugin unload hook", plugin::onUnLoad), new Cleanup("skull cache", () -> { if (plugin.getSkullCacheHandler() != null) plugin.getSkullCacheHandler().close(); }), - new Cleanup("full inventory handler", () -> { - FullInventoryHandler handler = plugin.getFullInventoryHandler(); - if (handler != null) { - handler.shutdown(); - handler.save(); - } - }), new Cleanup("hologram handler", () -> { if (plugin.getHologramHandler() != null) plugin.getHologramHandler().onShutDown(); }), diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java index 6eba089cec..134d284de2 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java @@ -61,6 +61,10 @@ public void shutdown() { shutdownNow(platform.getTimer()); shutdownNow(timeTimer); shutdownNow(platform.getInventoryTimer()); + await(platform.getLoginTimer(), 1, TimeUnit.SECONDS); + await(platform.getTimer(), 1, TimeUnit.SECONDS); + await(timeTimer, 1, TimeUnit.SECONDS); + await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); clean(platform.afterExecutorShutdown()); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java index 36a345335e..247d4b9502 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java @@ -5,12 +5,15 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -19,10 +22,16 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.ConcurrentHashMap; import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; @@ -33,8 +42,10 @@ import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.data.ServerData; import com.bencodez.simpleapi.scheduler.BukkitScheduler; @@ -117,6 +128,459 @@ public void giveItemAlwaysUsesEntityScheduler() { verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(player)); } + @Test + public void giveItemAsyncCompletesOnlyAfterOwnedInventoryDelivery() { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>()); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + assertFalse(delivery.toCompletableFuture().isDone()); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + assertTrue(delivery.toCompletableFuture().isDone()); + verify(inventory).addItem(item); + } + + @Test + public void noOverflowCompletionPrecedesShutdownSnapshot() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>()); + CountDownLatch saveStarted = new CountDownLatch(1); + doAnswer(invocation -> { + saveStarted.countDown(); + return null; + }).when(fixture.serverData).saveData(); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + AtomicReference shutdownThread = new AtomicReference<>(); + CompletionStage completionObserved = delivery.thenRun(() -> { + Thread shutdown = new Thread(fixture.handler::shutdown, "full-inventory-shutdown-race"); + shutdownThread.set(shutdown); + shutdown.start(); + try { + assertFalse(saveStarted.await(100, TimeUnit.MILLISECONDS), + "shutdown snapshot overtook the successful delivery completion"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + }); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + completionObserved.toCompletableFuture().get(2, TimeUnit.SECONDS); + Thread shutdown = shutdownThread.get(); + assertNotNull(shutdown); + shutdown.join(TimeUnit.SECONDS.toMillis(2)); + assertFalse(shutdown.isAlive()); + assertEquals(0, saveStarted.getCount()); + verify(inventory).addItem(item); + } + + @Test + public void giveItemAsyncWaitsForOverflowToBePersisted() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + CountDownLatch saveStarted = new CountDownLatch(1); + CountDownLatch releaseSave = new CountDownLatch(1); + doAnswer(invocation -> { + saveStarted.countDown(); + assertTrue(releaseSave.await(2, TimeUnit.SECONDS)); + return null; + }).when(fixture.serverData).saveData(); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + assertTrue(saveStarted.await(2, TimeUnit.SECONDS)); + assertFalse(delivery.toCompletableFuture().isDone()); + releaseSave.countDown(); + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + assertEquals(List.of(excess), fixture.handler.getItems().get(uuid)); + } + + @Test + public void replayCompletionIsRegisteredBeforeOverflowReservationCanRaceShutdown() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + doAnswer(invocation -> { + java.lang.reflect.Field completionsField = FullInventoryHandler.class + .getDeclaredField("replayOverflowCompletions"); + completionsField.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentHashMap> completions = + (ConcurrentHashMap>) completionsField + .get(fixture.handler); + assertEquals(1, completions.size(), + "shutdown must be able to find the completion before it can snapshot a reservation"); + return new HashMap<>(java.util.Map.of(0, excess)); + }).when(inventory).addItem(item); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + } + + @Test + public void replayDropFailurePersistsOnlyTheUndroppedOverflow() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + World world = mock(World.class); + Location location = mock(Location.class); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(true); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(location); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + doAnswer(invocation -> { + throw new IllegalStateException("world unavailable"); + }).when(world).dropItem(location, excess); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + CountDownLatch saveStarted = new CountDownLatch(1); + CountDownLatch releaseSave = new CountDownLatch(1); + doAnswer(invocation -> { + saveStarted.countDown(); + assertTrue(releaseSave.await(2, TimeUnit.SECONDS)); + return null; + }).when(fixture.serverData).saveData(); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + assertTrue(saveStarted.await(2, TimeUnit.SECONDS)); + assertFalse(delivery.toCompletableFuture().isDone()); + releaseSave.countDown(); + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + verify(inventory, times(1)).addItem(item); + verify(world).dropItem(location, excess); + assertEquals(List.of(excess), fixture.handler.getItems().get(uuid)); + assertEquals(excess, fixture.data.getItemStack("FullInventory." + uuid + ".Items.0")); + } + + @Test + public void replayOverflowSaveFailureDropsOnlyReservedOverflowWithoutReplayingPartialInsert() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + World world = mock(World.class); + Location location = mock(Location.class); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(false); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(location); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + when(world.dropItem(location, excess)).thenReturn(mock(org.bukkit.entity.Item.class)); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + doAnswer(invocation -> { + throw new IllegalStateException("disk unavailable"); + }).when(fixture.serverData).saveData(); + + AtomicInteger scheduled = new AtomicInteger(); + AtomicReference initialDelivery = new AtomicReference<>(); + AtomicReference fallback = new AtomicReference<>(); + CountDownLatch fallbackScheduled = new CountDownLatch(1); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + if (scheduled.incrementAndGet() == 1) initialDelivery.set(task); + else { + fallback.set(task); + fallbackScheduled.countDown(); + } + return null; + }).when(fixture.bukkitScheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(player)); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + initialDelivery.get().run(); + assertTrue(fallbackScheduled.await(2, TimeUnit.SECONDS)); + assertFalse(delivery.toCompletableFuture().isDone()); + + // The overflow remains reserved while its persistence is failing, so an + // ordinary sweep cannot consume it before the fallback owns it. + fixture.handler.check(); + assertEquals(2, scheduled.get()); + fallback.get().run(); + } + + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + verify(inventory, times(1)).addItem(item); + verify(world).dropItem(location, excess); + assertFalse(fixture.handler.getItems().containsKey(uuid)); + assertFalse(fixture.data.contains("FullInventory")); + } + + @Test + public void replayOverflowDoesNotAcknowledgeAnUnavailableFallback() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(false); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + doAnswer(invocation -> { + throw new IllegalStateException("disk unavailable"); + }).when(fixture.serverData).saveData(); + + AtomicReference initialDelivery = new AtomicReference<>(); + AtomicReference fallback = new AtomicReference<>(); + CountDownLatch fallbackScheduled = new CountDownLatch(1); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + if (initialDelivery.get() == null) initialDelivery.set(task); + else { + fallback.set(task); + fallbackScheduled.countDown(); + } + return null; + }).when(fixture.bukkitScheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(player)); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + initialDelivery.get().run(); + assertTrue(fallbackScheduled.await(2, TimeUnit.SECONDS)); + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + fallback.get().run(); + } + + assertFalse(delivery.toCompletableFuture().isDone()); + verify(inventory, times(1)).addItem(item); + assertFalse(fixture.handler.getItems().containsKey(uuid)); + assertFalse(fixture.data.contains("FullInventory")); + } + + @Test + public void shutdownPersistsAcceptedOverflowBeforeDiscardingQueuedPersistence() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(false); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + CountDownLatch executorBlocked = new CountDownLatch(1); + CountDownLatch releaseExecutor = new CountDownLatch(1); + fixture.handler.getTimer().execute(() -> { + executorBlocked.countDown(); + try { + releaseExecutor.await(2, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }); + assertTrue(executorBlocked.await(2, TimeUnit.SECONDS)); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + assertFalse(delivery.toCompletableFuture().isDone()); + + fixture.handler.shutdown(); + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + releaseExecutor.countDown(); + assertEquals(excess, fixture.data.getItemStack("FullInventory." + uuid + ".Items.0")); + verify(inventory, times(1)).addItem(item); + } + + @Test + public void queuedReplayDeliveryDoesNotStartAfterShutdownSnapshot() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + fixture.handler.shutdown(); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + java.util.concurrent.ExecutionException failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> delivery.toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertTrue(AdvancedCoreUser.isReplayActionNotStarted(failure)); + verify(inventory, never()).addItem(item); + } + + @Test + public void queuedPendingCheckCannotMutateItemsAfterShutdownSnapshot() { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.getInventory()).thenReturn(inventory); + fixture.handler.add(uuid, item); + + fixture.handler.check(player); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + fixture.handler.shutdown(); + assertEquals(item, fixture.data.getItemStack("FullInventory." + uuid + ".Items.0")); + + task.getValue().run(); + + verify(inventory, never()).addItem(any(ItemStack[].class)); + assertEquals(List.of(item), fixture.handler.getItems().get(uuid)); + } + + @Test + public void giveItemAsyncFailsWithoutMutationWhenPlayerDisconnectsAfterEnqueue() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.getInventory()).thenReturn(inventory); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + task.getValue().run(); + } + + java.util.concurrent.ExecutionException failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> delivery.toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertTrue(AdvancedCoreUser.isReplayActionNotStarted(failure)); + verify(inventory, never()).addItem(item); + } + + @Test + public void rejectedItemSchedulerSignalsThatDeliveryNeverStarted() throws Exception { + Fixture fixture = createFixture(); + Player player = mock(Player.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + org.mockito.Mockito.doThrow(new IllegalStateException("scheduler stopped")) + .when(fixture.bukkitScheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(player)); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + java.util.concurrent.ExecutionException failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> delivery.toCompletableFuture().get(2, TimeUnit.SECONDS)); + + assertTrue(AdvancedCoreUser.isReplayActionNotStarted(failure)); + } + + @Test + public void timedOutGiveItemAsyncSuppressesTheQueuedDeliveryToPreventReplayDuplicates() throws Exception { + Fixture fixture = createFixture(0L); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>()); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + java.util.concurrent.ExecutionException failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> delivery.toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertTrue(AdvancedCoreUser.isReplayActionNotStarted(failure)); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + + task.getValue().run(); + task.getValue().run(); + + verify(inventory, never()).addItem(item); + } + @Test public void handlerTimerIsIsolatedFromPluginInventoryTimer() { Fixture fixture = createFixture(); @@ -214,18 +678,30 @@ public void saveWaitsForInflightDeliveryBeforeSnapshotting() throws Exception { } private Fixture createFixture() { + return createFixture(TimeUnit.SECONDS.toMillis(30)); + } + + private Fixture createFixture(long itemDeliveryTimeoutMillis) { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); ScheduledExecutorService sharedInventoryTimer = mock(ScheduledExecutorService.class); BukkitScheduler bukkitScheduler = mock(BukkitScheduler.class); ServerData serverData = mock(ServerData.class); YamlConfiguration data = new YamlConfiguration(); when(plugin.getInventoryTimer()).thenReturn(sharedInventoryTimer); + when(plugin.getOptions()).thenReturn(options); + when(plugin.isEnabled()).thenReturn(true); when(plugin.getBukkitScheduler()).thenReturn(bukkitScheduler); when(plugin.getServerDataFile()).thenReturn(serverData); when(serverData.getData()).thenReturn(data); - FullInventoryHandler handler = new FullInventoryHandler(plugin); + FullInventoryHandler handler = new FullInventoryHandler(plugin) { + @Override + protected long getItemDeliveryTimeoutMillis() { + return itemDeliveryTimeoutMillis; + } + }; handlers.add(handler); return new Fixture(plugin, sharedInventoryTimer, bukkitScheduler, serverData, data, handler); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java index 5676c454af..ffd567a4a0 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -65,11 +66,10 @@ public void shutdownCoordinatesExecutorsAndInventoryPersistence() throws Excepti verify(timeTimer).shutdown(); verify(timer).awaitTermination(2, TimeUnit.SECONDS); verify(loginTimer).awaitTermination(2, TimeUnit.SECONDS); - verify(inventoryTimer).awaitTermination(1, TimeUnit.SECONDS); + verify(inventoryTimer, times(2)).awaitTermination(1, TimeUnit.SECONDS); verify(timeTimer).awaitTermination(2, TimeUnit.SECONDS); verify(rewardHandler).shutdown(); verify(fullInventoryHandler).shutdown(); - verify(fullInventoryHandler).save(); verify(plugin).onUnLoad(); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java index cdd00efd6f..31a2cb53f1 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java @@ -10,7 +10,11 @@ import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; import com.bencodez.advancedcore.core.platform.RuntimePlatform; import com.bencodez.advancedcore.core.platform.RuntimePlatform.Cleanup; @@ -51,11 +55,12 @@ private RuntimePlatform platform() { new AdvancedCoreRuntime(platform).shutdown(); assertEquals(List.of("pre", "login-stop", "timer-stop", "time-stop", "inventory-stop", "wait-log", "login-wait", "timer-wait", "time-wait", "inventory-wait", "rewards", - "login-force", "timer-force", "time-force", "inventory-force", "post"), events); + "login-force", "timer-force", "time-force", "inventory-force", + "login-wait", "timer-wait", "time-wait", "inventory-wait", "post"), events); verify(login).awaitTermination(2, TimeUnit.SECONDS); verify(timer).awaitTermination(2, TimeUnit.SECONDS); verify(time).awaitTermination(2, TimeUnit.SECONDS); - verify(inventory).awaitTermination(1, TimeUnit.SECONDS); + verify(inventory, times(2)).awaitTermination(1, TimeUnit.SECONDS); verify(platform, times(1)).getTimeTimer(); } @@ -107,4 +112,52 @@ private ScheduledExecutorService executor(String name, List events) thro assertNull(platform.getTimeTimer()); assertDoesNotThrow(() -> new AdvancedCoreLifecycle(null).shutdown()); } + + @Test void bukkitAdapterFlushesFullInventoryBeforeExecutorShutdown() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + FullInventoryHandler handler = mock(FullInventoryHandler.class); + MySQL mysql = mock(MySQL.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + when(plugin.getFullInventoryHandler()).thenReturn(handler); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(options.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + + platform.beforeExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("full inventory handler")) + .findFirst().orElseThrow().action().run(); + + verify(handler).shutdown(); + assertTrue(platform.beforeExecutorShutdown().stream() + .noneMatch(cleanup -> cleanup.name().equals("MySQL"))); + platform.afterExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("MySQL")) + .findFirst().orElseThrow().action().run(); + verify(mysql).close(); + assertTrue(platform.afterExecutorShutdown().stream() + .noneMatch(cleanup -> cleanup.name().equals("full inventory handler"))); + } + + @Test void bukkitAdapterDoesNotCloseMysqlWhileCheckpointTasksRemainActive() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(options.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + when(plugin.getTimer()).thenReturn(timer); + when(timer.isTerminated()).thenReturn(false); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + + platform.afterExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("MySQL")) + .findFirst().orElseThrow().action().run(); + + verify(mysql, never()).close(); + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java index 306995e729..034d8d664f 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java @@ -1,10 +1,16 @@ package com.bencodez.advancedcore.tests.misc; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -83,4 +89,115 @@ public void consoleCommandsStripLeadingSlashBeforeDispatch() { verify(server).dispatchCommand(console, "say hi"); } } + + @Test + public void asyncConsoleCommandCompletesOnlyAfterItsScheduledDispatch() { + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> Bukkit.getOfflinePlayer("Ben")).thenReturn(null); + + java.util.concurrent.CompletionStage completion = miscUtils.executeConsoleCommandsAsync("Ben", "/say hi", + new HashMap<>()); + verify(scheduler).executeOrScheduleSync(eq(plugin), task.capture()); + assertFalse(completion.toCompletableFuture().isDone()); + + task.getValue().run(); + completion.toCompletableFuture().join(); + assertTrue(completion.toCompletableFuture().isDone()); + verify(server).dispatchCommand(console, "say hi"); + } + } + + @Test + public void asyncConsoleCommandPreservesAnEmptyPlaceholderExpansion() { + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + HashMap placeholders = new HashMap<>(); + placeholders.put("command", ""); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> Bukkit.getOfflinePlayer("Ben")).thenReturn(null); + + java.util.concurrent.CompletionStage completion = miscUtils.executeConsoleCommandsAsync("Ben", + "%command%", placeholders); + verify(scheduler).executeOrScheduleSync(eq(plugin), task.capture()); + task.getValue().run(); + completion.toCompletableFuture().join(); + verify(server).dispatchCommand(console, ""); + } + } + + @Test + public void asyncStaggeredCommandsCompleteOnlyAfterEveryScheduledDispatch() { + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + ArgumentCaptor immediate = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor firstDelayed = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor secondDelayed = ArgumentCaptor.forClass(Runnable.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> Bukkit.getOfflinePlayer("Ben")).thenReturn(null); + + java.util.concurrent.CompletionStage completion = miscUtils.executeConsoleCommandsAsync("Ben", + new ArrayList<>(List.of("say one", "say two", "say three")), new HashMap<>(), true); + verify(scheduler).runTask(eq(plugin), immediate.capture()); + verify(scheduler, never()).runTaskLater(eq(plugin), any(Runnable.class), anyLong()); + assertFalse(completion.toCompletableFuture().isDone()); + + immediate.getValue().run(); + assertFalse(completion.toCompletableFuture().isDone()); + verify(scheduler).runTaskLater(eq(plugin), firstDelayed.capture(), eq(1L)); + firstDelayed.getValue().run(); + assertFalse(completion.toCompletableFuture().isDone()); + verify(scheduler, times(2)).runTaskLater(eq(plugin), secondDelayed.capture(), eq(1L)); + secondDelayed.getAllValues().get(1).run(); + completion.toCompletableFuture().join(); + + verify(server).dispatchCommand(console, "say one"); + verify(server).dispatchCommand(console, "say two"); + verify(server).dispatchCommand(console, "say three"); + } + } + + @Test + public void asyncNonStaggeredCommandsStayOnTheInitialServerTick() { + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + ArgumentCaptor initial = ArgumentCaptor.forClass(Runnable.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> Bukkit.getOfflinePlayer("Ben")).thenReturn(null); + + java.util.concurrent.CompletionStage completion = miscUtils.executeConsoleCommandsAsync("Ben", + new ArrayList<>(List.of("say one", "say two", "say three")), new HashMap<>(), false); + verify(scheduler).runTask(eq(plugin), initial.capture()); + assertFalse(completion.toCompletableFuture().isDone()); + + initial.getValue().run(); + completion.toCompletableFuture().join(); + + verify(scheduler).runTask(eq(plugin), any(Runnable.class)); + verify(scheduler, times(2)).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + verify(scheduler, never()).runTaskLater(eq(plugin), any(Runnable.class), anyLong()); + verify(server).dispatchCommand(console, "say one"); + verify(server).dispatchCommand(console, "say two"); + verify(server).dispatchCommand(console, "say three"); + } + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index c42ea695b6..6e46f2dd47 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -1,11 +1,16 @@ package com.bencodez.advancedcore.tests.rewards; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockConstruction; @@ -19,12 +24,17 @@ import java.util.HashMap; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.function.BiConsumer; import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Bukkit; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Answers; @@ -76,6 +86,7 @@ import com.bencodez.advancedcore.api.rewards.injected.RewardInjectString; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectStringList; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; public class BuiltinRewardBehaviorTest { @@ -128,11 +139,39 @@ public void expActuallyGivesExperienceAndLevels() { levels.set("Min", 8); levels.set("Max", 9); String levelResult = ((RewardInjectConfigurationSection) injects.get(3)) - .onRewardRequested(reward, user, levels, placeholders); + .onRewardRequested(reward, user, levels, new HashMap<>()); assertEquals("8", levelResult); verify(user).giveExpLevels(8); } + @Test + public void rangedExperienceReusesItsDurableSelectionOnRetry() { + RewardExp.register(handler, plugin); + RewardInjectConfigurationSection expInject = (RewardInjectConfigurationSection) injects.get(2); + ConfigurationSection exp = section("EXP"); + exp.set("Min", 5); + exp.set("Max", 6); + + assertEquals("5", expInject.onRewardRequested(reward, user, exp, placeholders)); + exp.set("Min", 8); + exp.set("Max", 9); + assertEquals("5", expInject.onRewardRequested(reward, user, exp, placeholders)); + + verify(user, org.mockito.Mockito.times(2)).giveExp(5); + verify(user, never()).giveExp(8); + } + + @Test + public void zeroExperienceScalarsDoNotInvokePlayerCallbacks() { + RewardExp.register(handler, plugin); + + ((RewardInjectInt) injects.get(0)).onRewardRequest(reward, user, 0, placeholders); + ((RewardInjectInt) injects.get(1)).onRewardRequest(reward, user, 0, placeholders); + + verify(user, never()).giveExp(org.mockito.ArgumentMatchers.anyInt()); + verify(user, never()).giveExpLevels(org.mockito.ArgumentMatchers.anyInt()); + } + @Test public void moneyActuallyGivesFixedAndRangedAmounts() { RewardMoney.register(handler, plugin); @@ -155,6 +194,26 @@ public void moneyActuallyGivesFixedAndRangedAmounts() { assertNotNull(result); } + @Test + public void rangedMoneyReusesItsDurableSelectionOnRetry() { + RewardMoney.register(handler, plugin); + RewardInjectConfigurationSection moneyInject = (RewardInjectConfigurationSection) injects.get(1); + ConfigurationSection money = section("Money"); + money.set("Min", 5.0); + money.set("Max", 6.0); + money.set("Round", false); + + String first = moneyInject.onRewardRequested(reward, user, money, placeholders); + money.set("Min", 8.0); + money.set("Max", 9.0); + String second = moneyInject.onRewardRequested(reward, user, money, placeholders); + + assertEquals(first, second); + ArgumentCaptor amount = ArgumentCaptor.forClass(Double.class); + verify(user, org.mockito.Mockito.times(2)).giveMoney(amount.capture()); + assertEquals(amount.getAllValues().get(0), amount.getAllValues().get(1)); + } + @Test public void actionBarActuallySendsConfiguredMessage() { RewardActionBar.register(handler, plugin); @@ -168,6 +227,27 @@ public void actionBarActuallySendsConfiguredMessage() { verify(user).sendActionBar("Hello Ben", 42); } + @Test + public void emptyActionBarDoesNotRequireAnOnlinePlayer() { + RewardActionBar.register(handler, plugin); + YamlConfiguration data = new YamlConfiguration(); + data.createSection("ActionBar"); + RewardInject inject = injects.get(0); + + assertFalse(inject.isPlayerRequiredFor(data, placeholders)); + data.set("ActionBar.Message", "Visible"); + assertTrue(inject.isPlayerRequiredFor(data, placeholders)); + } + + @Test + public void expScalarsRequireAnOnlinePlayerOnlyWhenNonZero() { + RewardExp.register(handler, plugin); + YamlConfiguration data = new YamlConfiguration(); + + assertScalarPlayerRequirement(injects.get(0), data, "EXP"); + assertScalarPlayerRequirement(injects.get(1), data, "EXPLevels"); + } + @Test public void bossBarActuallySendsConfiguredBossBar() { RewardBossBar.register(handler, plugin); @@ -253,12 +333,30 @@ public void temporaryPermissionActuallyAddsPermission() { ConfigurationSection section = section("TempPermission"); section.set("Permission", "advancedcore.test"); section.set("Expiration", 90); + when(user.getPlayer()).thenReturn(mock(Player.class)); configInject(0).onRewardRequested(reward, user, section, placeholders); verify(user).addPermission("advancedcore.test", 90); } + @Test + public void temporaryPermissionFailurePropagatesDuringAsyncReplay() { + RewardTempPermission.register(handler, plugin); + ConfigurationSection data = new YamlConfiguration(); + ConfigurationSection section = data.createSection("TempPermission"); + section.set("Permission", "advancedcore.test"); + section.set("Expiration", 90); + when(user.getPlayer()).thenReturn(null); + + RewardInject inject = injects.get(0); + assertTrue(inject.supportsAsyncRequest()); + assertTrue(inject.requiresConfiguredDataForAsync()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> inject.onRewardRequestAsync(reward, user, data, placeholders).toCompletableFuture().join()); + verify(user, never()).addPermission(anyString(), org.mockito.ArgumentMatchers.anyLong()); + } + @Test public void commandsActuallyDispatchEveryCommandForm() { RewardCommands.register(handler, plugin); @@ -298,6 +396,103 @@ public void commandsActuallyDispatchEveryCommandForm() { } } + @Test + public void asyncCommandsWaitForTheirDispatchStages() { + RewardCommands.register(handler, plugin); + MiscUtils misc = mock(MiscUtils.class); + CompletableFuture numberCommand = new CompletableFuture<>(); + CompletableFuture command = new CompletableFuture<>(); + CompletableFuture commandList = new CompletableFuture<>(); + CompletableFuture sectionConsole = new CompletableFuture<>(); + CompletableFuture sectionPlayer = new CompletableFuture<>(); + CompletableFuture random = new CompletableFuture<>(); + try (MockedStatic miscStatic = mockStatic(MiscUtils.class)) { + miscStatic.when(MiscUtils::getInstance).thenReturn(misc); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("give Ben stone 5"), eq(placeholders))) + .thenReturn(numberCommand); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("say hi"), eq(placeholders))).thenReturn(command); + ArrayList list = new ArrayList<>(List.of("say one", "say two")); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq(list), eq(placeholders), eq(true))) + .thenReturn(commandList); + ArrayList console = new ArrayList<>(List.of("say console")); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq(console), eq(placeholders), eq(false))) + .thenReturn(sectionConsole); + ArrayList player = new ArrayList<>(List.of("spawn")); + when(user.validatePlayerCommandAvailabilityAsync()).thenReturn(CompletableFuture.completedFuture(null)); + when(user.preformCommandAsync(eq(player), eq(placeholders))).thenReturn(sectionPlayer); + ArrayList randomList = new ArrayList<>(List.of("say random")); + HashMap randomPlaceholders = new HashMap<>(); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("say random"), eq(randomPlaceholders))).thenReturn(random); + + ConfigurationSection number = section("NumberCommand"); + number.set("Min", 5); + number.set("Max", 5); + number.set("Command", "give Ben stone %number%"); + CompletionStage numberStage = ((RewardInjectConfigurationSection) injects.get(0)) + .onRewardRequestedAsync(reward, user, number, placeholders); + assertFalse(numberStage.toCompletableFuture().isDone()); + numberCommand.complete(null); + assertEquals("5", numberStage.toCompletableFuture().join()); + + CompletionStage commandStage = ((RewardInjectString) injects.get(1)) + .onRewardRequestAsync(reward, user, "say hi", placeholders); + assertFalse(commandStage.toCompletableFuture().isDone()); + command.complete(null); + commandStage.toCompletableFuture().join(); + + CompletionStage listStage = ((RewardInjectStringList) injects.get(2)) + .onRewardRequestAsync(reward, user, list, placeholders); + assertFalse(listStage.toCompletableFuture().isDone()); + commandList.complete(null); + listStage.toCompletableFuture().join(); + + ConfigurationSection commands = section("Commands"); + commands.set("Console", console); + commands.set("Player", player); + commands.set("Stagger", false); + CompletionStage sectionStage = ((RewardInjectConfigurationSection) injects.get(3)) + .onRewardRequestedAsync(reward, user, commands, placeholders); + verify(misc).executeConsoleCommandsAsync(eq("Ben"), eq(console), eq(placeholders), eq(false)); + verify(user, never()).preformCommandAsync(eq(player), eq(placeholders)); + sectionConsole.complete(null); + verify(user).preformCommandAsync(eq(player), eq(placeholders)); + assertFalse(sectionStage.toCompletableFuture().isDone()); + sectionPlayer.complete(null); + sectionStage.toCompletableFuture().join(); + + CompletionStage randomStage = ((RewardInjectStringList) injects.get(4)) + .onRewardRequestAsync(reward, user, randomList, randomPlaceholders); + assertFalse(randomStage.toCompletableFuture().isDone()); + random.complete(null); + randomStage.toCompletableFuture().join(); + } + } + + @Test + public void mixedAsyncCommandsDoNotScheduleConsoleWorkWhenPlayerDispatchFails() { + RewardCommands.register(handler, plugin); + MiscUtils misc = mock(MiscUtils.class); + ArrayList console = new ArrayList<>(List.of("give Ben diamond")); + ArrayList player = new ArrayList<>(List.of("spawn")); + when(user.validatePlayerCommandAvailabilityAsync()) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("player unavailable"))); + ConfigurationSection commands = section("Commands"); + commands.set("Console", console); + commands.set("Player", player); + + try (MockedStatic miscStatic = mockStatic(MiscUtils.class)) { + miscStatic.when(MiscUtils::getInstance).thenReturn(misc); + + CompletionStage result = ((RewardInjectConfigurationSection) injects.get(3)) + .onRewardRequestedAsync(reward, user, commands, placeholders); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + verify(misc, never()).executeConsoleCommandsAsync(anyString(), any(), any(), anyBoolean()); + verify(misc, never()).executeConsoleCommandsAsync(anyString(), anyString(), any()); + verify(user, never()).preformCommandAsync(any(), any()); + } + } + @Test public void messagesActuallySendPlayerAndBroadcastForms() { RewardMessages.register(handler, plugin); @@ -335,11 +530,26 @@ public void messagesActuallySendPlayerAndBroadcastForms() { @Test public void itemRewardsActuallyGiveBuiltItems() { - try (MockedConstruction builders = mockConstruction(ItemBuilder.class, - withSettings().defaultAnswer(Answers.RETURNS_SELF))) { + when(user.getPlayer()).thenReturn(mock(Player.class)); + ItemStack frozenStack = mock(ItemStack.class); + when(frozenStack.serialize()).thenReturn(java.util.Map.of("v", 0, "type", "STONE", "amount", 2)); + org.bukkit.UnsafeValues unsafe = mock(org.bukkit.UnsafeValues.class); + when(unsafe.getMaterial("STONE", 0)).thenReturn(Material.STONE); + org.bukkit.inventory.ItemFactory itemFactory = mock(org.bukkit.inventory.ItemFactory.class); + when(itemFactory.equals(any(), any())).thenReturn(true); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedConstruction builders = mockConstruction(ItemBuilder.class, + withSettings().defaultAnswer(Answers.RETURNS_SELF), + (builder, context) -> when(builder.toItemStack(any(Player.class))) + .thenReturn(frozenStack))) { + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + bukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); RewardItems.registerItem(handler, plugin); configInject(0).onRewardRequested(reward, user, section("Item"), placeholders); - verify(user).giveItem(any(ItemBuilder.class)); + int buildersAfterItem = builders.constructed().size(); + configInject(0).onRewardRequested(reward, user, section("Item"), placeholders); + assertEquals(buildersAfterItem, builders.constructed().size()); + verify(user, org.mockito.Mockito.times(2)).giveItem(any(ItemStack.class)); injects.clear(); RewardItems.registerRandomItem(handler, plugin); @@ -348,18 +558,64 @@ public void itemRewardsActuallyGiveBuiltItems() { String selected = ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, random.getKeys(false), random, placeholders); assertEquals("OnlyItem", selected); - verify(user, atLeastOnce()).giveItem(any(ItemBuilder.class)); + int buildersBeforeReplay = builders.constructed().size(); + random.set("OnlyItem", null); + random.createSection("OtherItem"); + assertEquals("OnlyItem", ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, + java.util.Set.of("OtherItem"), random, placeholders)); + assertEquals(buildersBeforeReplay, builders.constructed().size()); + verify(user, atLeastOnce()).giveItem(any(ItemStack.class)); injects.clear(); RewardItems.registerItems(handler, plugin); ConfigurationSection items = section("Items"); items.createSection("FirstItem"); ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, items.getKeys(false), items, placeholders); - verify(user, atLeastOnce()).giveItem(any(ItemBuilder.class)); + int buildersAfterItems = builders.constructed().size(); + ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, items.getKeys(false), items, placeholders); + assertEquals(buildersAfterItems, builders.constructed().size()); + verify(user, atLeastOnce()).giveItem(any(ItemStack.class)); assertTrue(builders.constructed().size() >= 3); } } + @Test + public void onlyOneItemChanceReplaysFailedRollBeforeSuccessfulRoll() { + when(user.getPlayer()).thenReturn(mock(Player.class)); + when(reward.getConfig().getConfigData().getBoolean("OnlyOneItemChance", false)).thenReturn(true); + ItemStack frozenStack = mock(ItemStack.class); + when(frozenStack.serialize()).thenReturn(java.util.Map.of("v", 0, "type", "STONE", "amount", 1)); + org.bukkit.UnsafeValues unsafe = mock(org.bukkit.UnsafeValues.class); + when(unsafe.getMaterial("STONE", 0)).thenReturn(Material.STONE); + org.bukkit.inventory.ItemFactory itemFactory = mock(org.bukkit.inventory.ItemFactory.class); + when(itemFactory.equals(any(), any())).thenReturn(true); + java.util.concurrent.atomic.AtomicInteger construction = new java.util.concurrent.atomic.AtomicInteger(); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedConstruction builders = mockConstruction(ItemBuilder.class, + withSettings().defaultAnswer(Answers.RETURNS_SELF), (builder, context) -> { + when(builder.toItemStack(any(Player.class))).thenReturn(frozenStack); + if (!context.arguments().isEmpty() + && context.arguments().get(0) instanceof ConfigurationSection) { + when(builder.isChancePass()).thenReturn(construction.getAndIncrement() == 1); + } + })) { + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + bukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); + RewardItems.registerItems(handler, plugin); + ConfigurationSection items = section("Items"); + items.createSection("First"); + items.createSection("Second"); + java.util.Set ordered = new java.util.LinkedHashSet<>(java.util.List.of("First", "Second")); + + assertEquals("Second", ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, + ordered, items, placeholders)); + assertEquals(2, construction.get()); + assertEquals("Second", ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, + ordered, items, placeholders)); + assertEquals(2, construction.get(), "replay must reuse both frozen chance decisions"); + } + } + @Test public void fireworkActuallyLaunchesConfiguredFirework() { RewardFirework.register(handler, plugin); @@ -557,6 +813,51 @@ public void choicesActuallyQueuesOrDispatchesSelectedChoice() { verify(handler).giveChoicesReward(reward, user, "OptionA"); } + @Test + public void choicesAsyncDispatchWaitsForSelectedReward() { + RewardChoices.register(handler, plugin); + RewardInjectBoolean choices = (RewardInjectBoolean) injects.get(0); + when(user.getChoicePreference("SourceReward")).thenReturn("OptionA"); + CompletableFuture child = new CompletableFuture<>(); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + + try (MockedConstruction builders = mockConstruction(RewardBuilder.class, + withSettings().defaultAnswer(Answers.RETURNS_SELF), + (builder, context) -> when(builder.sendAsync(user)).thenReturn(child))) { + CompletionStage result = choices.onRewardRequestAsync(reward, user, true, placeholders); + assertFalse(result.toCompletableFuture().isDone()); + child.complete(null); + assertEquals("OptionA", result.toCompletableFuture().join()); + verify(builders.constructed().get(0)).sendAsync(user); + verify(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + } + } + + @Test + public void choicesRetireConsumedOccurrenceAfterOwningCheckpointPersists() { + RewardChoices.register(handler, plugin); + RewardInjectBoolean choices = (RewardInjectBoolean) injects.get(0); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + + choices.onReplayCheckpointPersisted(reward, user, "occurrence-one", "SourceReward/0") + .toCompletableFuture().join(); + + verify(user).checkpointUnClaimedChoiceReward( + Reward.replaySideEffectOccurrenceId("occurrence-one", "SourceReward/0")); + } + @Test public void javascriptActuallyExecutesScriptsAndTrueBranch() { RewardJavascript.register(handler, plugin); @@ -594,6 +895,16 @@ private ConfigurationSection section(String name) { return new YamlConfiguration().createSection(name); } + private void assertScalarPlayerRequirement(RewardInject inject, YamlConfiguration data, String path) { + assertFalse(inject.isPlayerRequiredFor(data, placeholders), path + " is absent"); + data.set(path, 0); + assertFalse(inject.isPlayerRequiredFor(data, placeholders), path + " is zero"); + data.set(path, 1); + assertTrue(inject.isPlayerRequiredFor(data, placeholders), path + " is positive"); + data.set(path, -1); + assertTrue(inject.isPlayerRequiredFor(data, placeholders), path + " is negative"); + } + @SuppressWarnings("unused") private RewardInject register(BiConsumer registrar) { injects.clear(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/QueuedGeneratedRewardDispatchTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/QueuedGeneratedRewardDispatchTest.java index 6d3c8b7fc1..d036084c6a 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/QueuedGeneratedRewardDispatchTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/QueuedGeneratedRewardDispatchTest.java @@ -6,12 +6,16 @@ import static org.mockito.Mockito.when; import java.nio.charset.StandardCharsets; +import java.io.File; import java.util.Base64; +import java.util.Set; import java.util.logging.Logger; +import java.util.concurrent.CompletionException; import org.bukkit.Bukkit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.mockito.MockedStatic; import com.bencodez.advancedcore.AdvancedCorePlugin; @@ -23,6 +27,8 @@ import com.bencodez.advancedcore.api.user.AdvancedCoreUser; class QueuedGeneratedRewardDispatchTest { + @TempDir + File tempDir; private static final String QUEUE_PREFIX = "\\AdvancedCoreQueue/1/"; @@ -235,4 +241,23 @@ void normalStandaloneDispatchCannotResolveGeneratedSnapshot() { verify(handler, never()).getQueuedGeneratedReward("QueuedReward", "uuid"); } + + @Test + void asyncGeneratedSnapshotRejectsAUserOutsideItsPersistedQueueCapability() throws Exception { + AdvancedCorePlugin.setInstance(plugin); + try { + java.lang.reflect.Constructor constructor = Class + .forName("com.bencodez.advancedcore.api.rewards.QueuedGeneratedReward") + .getDeclaredConstructor(File.class, String.class, Set.class); + constructor.setAccessible(true); + Reward queued = (Reward) constructor.newInstance(tempDir, "QueuedReward", Set.of("allowed")); + + org.junit.jupiter.api.Assertions.assertThrows(CompletionException.class, + () -> queued.giveRewardAsync(user, new RewardOptions()).toCompletableFuture().join()); + + verify(plugin, never()).getOptions(); + } finally { + AdvancedCorePlugin.setInstance(null); + } + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java new file mode 100644 index 0000000000..d8683af4a4 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java @@ -0,0 +1,3094 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Answers; +import org.mockito.MockedConstruction; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.VaultHandler; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardBuilder; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.rewards.builtin.RewardExp; +import com.bencodez.advancedcore.api.rewards.builtin.RewardItems; +import com.bencodez.advancedcore.api.rewards.builtin.RewardPotions; +import com.bencodez.advancedcore.api.rewards.builtin.RewardSound; +import com.bencodez.advancedcore.api.rewards.injected.RewardInject; +import com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt; +import com.bencodez.advancedcore.api.rewards.injected.RewardInjectString; +import com.bencodez.advancedcore.api.rewards.builtin.RewardSubRewards; +import com.bencodez.advancedcore.api.rewards.builtin.RewardRandomReward; +import com.bencodez.advancedcore.api.rewards.builtin.RewardJavascript; +import com.bencodez.advancedcore.api.rewards.builtin.RewardChoices; +import com.bencodez.advancedcore.api.rewards.builtin.RewardCommands; +import com.bencodez.advancedcore.api.rewards.builtin.RewardSpecialChance; +import com.bencodez.advancedcore.api.javascript.JavascriptEngine; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject; +import com.bencodez.advancedcore.thread.FileThread; + +import net.milkbowl.vault.economy.Economy; + +class RewardAsyncInjectionTest { + + @TempDir + File tempDir; + + private AdvancedCorePlugin plugin; + private RewardHandler handler; + private Reward reward; + private ConfigurationSection data; + private AdvancedCoreUser user; + private AtomicBoolean enabled; + private com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler; + + @BeforeEach + void setUp() { + plugin = mock(AdvancedCorePlugin.class); + enabled = new AtomicBoolean(true); + when(plugin.isEnabled()).thenAnswer(ignored -> enabled.get()); + when(plugin.getDataFolder()).thenReturn(tempDir); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + AdvancedCorePlugin.setInstance(plugin); + try { + java.lang.reflect.Field pluginField = FileThread.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(FileThread.getInstance(), plugin); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + handler = new RewardHandler(plugin); + when(plugin.getRewardHandler()).thenReturn(handler); + + YamlConfiguration configuration = new YamlConfiguration(); + data = configuration.createSection("Reward"); + reward = new Reward("AsyncReward", data); + try { + java.lang.reflect.Field pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(reward, plugin); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + user = mock(AdvancedCoreUser.class); + } + + @AfterEach + void tearDown() { + handler.getDelayedTimer().shutdownNow(); + AdvancedCorePlugin.setInstance(null); + } + + @Test + void reusedTopLevelOptionsDoNotShareCompletedReplayState() { + AtomicInteger invocations = new AtomicInteger(); + Player player = mock(Player.class); + when(user.getPlayer()).thenReturn(player); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + handler.getInjectedRewards().add(new RewardInject("Async") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invocations.incrementAndGet(); + return CompletableFuture.completedFuture(null); + } + }); + RewardOptions options = new RewardOptions().forceOffline(); + + Reward.ReplayState firstDispatch = Reward.replayStateFor(options); + Reward.ReplayState secondDispatch = Reward.replayStateFor(options); + reward.giveRewardUserAsync(user, new HashMap<>(), options).toCompletableFuture().join(); + reward.giveRewardUserAsync(user, new HashMap<>(), options).toCompletableFuture().join(); + + assertNotSame(firstDispatch, secondDispatch); + assertEquals(2, invocations.get()); + assertNull(options.getAsyncReplayState()); + assertNull(options.getAsyncReplayKey()); + assertNull(options.getAsyncReplayOccurrenceId()); + } + + @Test + void reusableFreshPlaceholdersDoNotRetainLegacyActionCompletionMarkers() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Reusable"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(2); + return null; + } + }); + HashMap placeholders = new HashMap<>(); + UUID uuid = UUID.fromString(realUser.getUUID()); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + reward.giveInjectedRewardsAsync(realUser, placeholders).toCompletableFuture().join(); + reward.giveInjectedRewardsAsync(realUser, placeholders).toCompletableFuture().join(); + } + + verify(economy, times(2)).depositPlayer(offlinePlayer, 2); + assertFalse(placeholders.keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_legacy_actions_"))); + } + + @Test + void legacyScheduledRewardsWaitForQueuedBukkitActions() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + FullInventoryHandler inventory = mock(FullInventoryHandler.class); + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + CompletableFuture itemDelivery = new CompletableFuture<>(); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + realUser.setPlayerName("Legacy"); + Player player = mock(Player.class); + when(inventory.giveItemAsync(eq(player), any(ItemStack.class))).thenReturn(itemDelivery); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(2); + realUser.giveItem(new ItemStack(Material.STONE)); + return null; + } + }); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + assertFalse(result.toCompletableFuture().isDone()); + assertEquals(1, queued.size()); + queued.remove(0).run(); + assertTrue(queued.isEmpty(), "replay hands the item directly to the awaited inventory task"); + assertFalse(result.toCompletableFuture().isDone(), "the inner item delivery still owns completion"); + itemDelivery.complete(null); + result.toCompletableFuture().join(); + } + verify(economy).depositPlayer(offlinePlayer, 2); + verify(inventory).giveItemAsync(eq(player), any(ItemStack.class)); + } + + @Test + void ordinaryItemGrantUsesTheVoidInventoryApiWithoutAnAsyncTimeout() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + FullInventoryHandler inventory = mock(FullInventoryHandler.class); + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + Player player = mock(Player.class); + ItemStack item = new ItemStack(Material.STONE); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + realUser.giveItem(item); + } + + verify(inventory).giveItem(player, item); + verify(inventory, never()).giveItemAsync(any(Player.class), any(ItemStack.class)); + } + + @Test + void replayItemsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + FullInventoryHandler inventory = mock(FullInventoryHandler.class); + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + realUser.setPlayerName("Dispatch"); + Player player = mock(Player.class); + when(player.getDisplayName()).thenReturn("Dispatch"); + AtomicReference availablePlayer = new AtomicReference<>(player); + AtomicReference itemType = new AtomicReference<>(Material.STONE); + AtomicReference queuedInjection = new AtomicReference<>(); + doAnswer(invocation -> { + queuedInjection.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + data.createSection("Items").createSection("Stone").set("Material", "STONE"); + RewardItems.registerItems(handler, plugin); + org.bukkit.UnsafeValues unsafe = mock(org.bukkit.UnsafeValues.class); + when(unsafe.getMaterial(any(String.class), eq(0))) + .thenAnswer(invocation -> Material.matchMaterial(invocation.getArgument(0, String.class))); + org.bukkit.inventory.ItemFactory itemFactory = mock(org.bukkit.inventory.ItemFactory.class); + when(itemFactory.equals(any(), any())).thenReturn(true); + + try (MockedConstruction builders = mockConstruction(ItemBuilder.class, (builder, context) -> { + when(builder.setPlaceholders(any(HashMap.class))).thenReturn(builder); + when(builder.toItemStack(any(Player.class))).thenAnswer(ignored -> new ItemStack(itemType.get())); + }); org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenAnswer(ignored -> availablePlayer.get()); + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + bukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); + CompletionStage delivery = reward.giveRewardUserAsync(realUser, new HashMap<>(), new RewardOptions()); + assertFalse(delivery.toCompletableFuture().isDone(), "the initial online check only queues injection dispatch"); + assertNotNull(queuedInjection.get()); + + availablePlayer.set(null); + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> { + queuedInjection.get().run(); + delivery.toCompletableFuture().join(); + }); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertEquals(0, checkpoint.getCompletedInjectionCount(), checkpoint.getReplayProgress().toString()); + assertEquals(0, checkpoint.getReplayProgress().getOrDefault("AsyncReward", 0)); + assertTrue(checkpoint.getReplayPlaceholders().entrySet().stream().noneMatch(entry -> + entry.getKey().startsWith("__advancedcore_replay_legacy_actions_") + && !entry.getKey().endsWith("_snapshot")), + "an undelivered item must not receive an action-completion checkpoint"); + verify(inventory, never()).giveItemAsync(any(Player.class), any(ItemStack.class)); + + availablePlayer.set(player); + itemType.set(Material.DIRT); + when(inventory.giveItemAsync(eq(player), any(ItemStack.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + resumed.toCompletableFuture().join(); + + availablePlayer.set(null); + AdvancedCoreUser.AsyncActionCollection placeholderContext = realUser.beginAsyncActionCollection(); + realUser.giveItem(new ItemStack(Material.STONE), new HashMap<>()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> realUser.endAsyncActionCollection(placeholderContext).toCompletableFuture().join(), + "placeholder-expanded items use the same replay-only unavailable-player failure"); + } + verify(inventory).giveItemAsync(eq(player), any(ItemStack.class)); + } + + @Test + void replayPotionsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + realUser.setPlayerName("PotionDispatch"); + Player player = mock(Player.class); + when(player.getDisplayName()).thenReturn("PotionDispatch"); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + AtomicReference availablePlayer = new AtomicReference<>(player); + AtomicReference queuedInjection = new AtomicReference<>(); + doAnswer(invocation -> { + queuedInjection.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + data.createSection("Potions").createSection("SPEED").set("Duration", 1); + RewardPotions.register(handler, plugin); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenAnswer(ignored -> availablePlayer.get()); + CompletionStage delivery = reward.giveRewardUserAsync(realUser, new HashMap<>(), new RewardOptions()); + assertFalse(delivery.toCompletableFuture().isDone()); + availablePlayer.set(null); + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> { + queuedInjection.get().run(); + delivery.toCompletableFuture().join(); + }); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertEquals(0, checkpoint.getCompletedInjectionCount()); + assertEquals(0, checkpoint.getReplayProgress().getOrDefault("AsyncReward", 0)); + verify(player, never()).addPotionEffect(any()); + + availablePlayer.set(player); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + AtomicReference queuedPotionDelivery = new AtomicReference<>(); + doAnswer(invocation -> { + queuedPotionDelivery.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertFalse(resumed.toCompletableFuture().isDone(), "recovery must requeue the unfinished potion action"); + assertNotNull(queuedPotionDelivery.get()); + availablePlayer.set(null); + queuedPotionDelivery.get().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + } + verify(player, never()).addPotionEffect(any()); + } + + @Test + void replayExperienceRemainsPendingForNullAndDisconnectedPlayers() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + realUser.setPlayerName("ExperienceDispatch"); + Player player = mock(Player.class); + when(player.getDisplayName()).thenReturn("ExperienceDispatch"); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + AtomicReference availablePlayer = new AtomicReference<>(player); + AtomicReference queuedInjection = new AtomicReference<>(); + doAnswer(invocation -> { + queuedInjection.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + data.set("EXP", 5); + RewardExp.register(handler, plugin); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenAnswer(ignored -> availablePlayer.get()); + CompletionStage delivery = reward.giveRewardUserAsync(realUser, new HashMap<>(), new RewardOptions()); + assertFalse(delivery.toCompletableFuture().isDone()); + availablePlayer.set(null); + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> { + queuedInjection.get().run(); + delivery.toCompletableFuture().join(); + }); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertEquals(0, checkpoint.getCompletedInjectionCount()); + verify(player, never()).giveExp(5); + + availablePlayer.set(player); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + AtomicReference queuedExperience = new AtomicReference<>(); + doAnswer(invocation -> { + queuedExperience.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertFalse(resumed.toCompletableFuture().isDone()); + assertNotNull(queuedExperience.get()); + availablePlayer.set(null); + queuedExperience.get().run(); + Throwable scheduledFailure = assertThrows(java.util.concurrent.CompletionException.class, + () -> resumed.toCompletableFuture().join()); + Reward.RewardReplayFailure retryCheckpoint = findCheckpoint(scheduledFailure); + + // The disconnected owner task never mutated the player, so its reservation + // must be released and a changed retry payload must be accepted. + data.set("EXP", 7); + availablePlayer.set(player); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + CompletionStage changedRetry = (CompletionStage) replay.invoke(reward, realUser, + retryCheckpoint.getReplayPlaceholders(), 0, + state.newInstance(retryCheckpoint.getReplayProgress(), + retryCheckpoint.getReplayRegistryFingerprints(), false), "AsyncReward"); + changedRetry.toCompletableFuture().join(); + verify(player).giveExp(7); + } + verify(player, never()).giveExp(5); + } + + @Test + void offlineRequeueRetainsReplayStateAndLegacyActionMarkers() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isPauseRewards()).thenReturn(false); + when(config.isTreatVanishAsOffline()).thenReturn(false); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.isOnline()).thenReturn(false); + when(user.getPlayerName()).thenReturn("Offline"); + RewardOptions options = new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true).setGiveOffline(true); + options.setOnline(false); + options.setAsyncReplayProgress(Map.of("AsyncReward", 1)); + options.setAsyncReplayRegistryFingerprints(Map.of("AsyncReward", "registry-fingerprint")); + options.getPlaceholders().put("__advancedcore_replay_legacy_actions_marker", "v2:completed"); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + reward.giveReward(user, options); + } + + ArgumentCaptor queuedOptions = ArgumentCaptor.forClass(RewardOptions.class); + verify(user).addOfflineRewards(eq(reward), eq(options.getPlaceholders()), queuedOptions.capture()); + assertEquals(options, queuedOptions.getValue()); + assertEquals(1, queuedOptions.getValue().getAsyncReplayProgress().get("AsyncReward")); + assertEquals("registry-fingerprint", queuedOptions.getValue().getAsyncReplayRegistryFingerprints().get("AsyncReward")); + assertEquals("v2:completed", queuedOptions.getValue().getPlaceholders() + .get("__advancedcore_replay_legacy_actions_marker")); + } + + @Test + void durableReplayFailsWhenProcessingIsDisabledBeforeDispatch() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(false); + when(plugin.getOptions()).thenReturn(config); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(ignored -> { }); + + assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveRewardAsync(user, options).toCompletableFuture().join()); + } + + @Test + void timedReplayDeferralRemainsInTheTimedQueue() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isPauseRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.getPlayerName()).thenReturn("Offline"); + RewardOptions options = new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true); + options.setTimedQueueReplay(true); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveRewardAsync(user, options).toCompletableFuture().join()); + } + + verify(user, never()).addOfflineRewards(any(), any(), any()); + } + + @Test + void inheritedDurableChildDefersToItsParentQueue() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isPauseRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.getPlayerName()).thenReturn("Offline"); + RewardOptions owner = new RewardOptions(); + owner.setAsyncReplayCheckpointConsumer(ignored -> { }); + RewardOptions child = new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true); + child.setAsyncReplayState(Reward.replayStateFor(owner)); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + java.util.concurrent.CompletionException failure = assertThrows( + java.util.concurrent.CompletionException.class, + () -> reward.giveRewardAsync(user, child).toCompletableFuture().join()); + assertTrue(Reward.isOfflineReplayDeferred(failure)); + } + + verify(user, never()).addOfflineRewards(any(), any(), any()); + } + + @Test + void freshAsyncDispatchAlwaysUsesCompletionAwareRewardUserPath() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.isOnline()).thenReturn(true); + when(user.getPlayer()).thenReturn(mock(Player.class)); + Reward spyReward = org.mockito.Mockito.spy(reward); + CompletableFuture legacyActions = new CompletableFuture<>(); + doReturn(legacyActions).when(spyReward).giveRewardUserAsync(eq(user), any(HashMap.class), + any(RewardOptions.class)); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + CompletionStage result; + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + result = spyReward.giveRewardAsync(user, + new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true)); + } + + assertFalse(result.toCompletableFuture().isDone()); + legacyActions.complete(null); + result.toCompletableFuture().join(); + verify(spyReward).giveRewardUserAsync(eq(user), any(HashMap.class), any(RewardOptions.class)); + } + + @Test + void directAsyncDispatchLeavesBukkitPrimaryThreadBeforeFiringEvent() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.isOnline()).thenReturn(true); + when(user.getPlayer()).thenReturn(mock(Player.class)); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + ArgumentCaptor asyncTask = ArgumentCaptor.forClass(Runnable.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + CompletionStage result = reward.giveRewardAsync(user, + new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true)); + + verify(scheduler).runTaskAsynchronously(eq(plugin), asyncTask.capture()); + verify(pluginManager, never()).callEvent(any()); + assertFalse(result.toCompletableFuture().isDone()); + asyncTask.getValue().run(); + result.toCompletableFuture().join(); + verify(pluginManager).callEvent(any()); + } + } + + @Test + void timedOutPrimaryThreadHandoffCannotRunLateRewardSideEffects() throws Exception { + Reward shortTimeoutReward = new Reward("AsyncReward", data) { + @Override + protected long getServerThreadDispatchTimeoutMillis() { + return 25; + } + }; + java.lang.reflect.Field pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(shortTimeoutReward, plugin); + ArgumentCaptor delayedTask = ArgumentCaptor.forClass(Runnable.class); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + CompletionStage result = shortTimeoutReward.giveRewardAsync(user, + new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true)); + + verify(scheduler).runTaskAsynchronously(eq(plugin), delayedTask.capture()); + assertThrows(CompletionException.class, () -> result.toCompletableFuture().join()); + delayedTask.getValue().run(); + verify(pluginManager, never()).callEvent(any()); + } + } + + @Test + void durableReplayRetainsOccurrenceWhenRequirementEvaluationThrows() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.isOnline()).thenReturn(true); + when(user.getPlayer()).thenReturn(mock(Player.class)); + AtomicBoolean injected = new AtomicBoolean(); + handler.getInjectedRequirements().add(new RequirementInject("TransientRequirement") { + @Override + public boolean onRequirementRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, RewardOptions ignoredOptions) { + throw new IllegalStateException("requirement service unavailable"); + } + }); + handler.getInjectedRewards().add( + asyncInjection("MustNotRun", CompletableFuture.completedFuture(null), injected)); + RewardOptions options = new RewardOptions().setCheckTimed(false); + options.setAsyncReplayCheckpointConsumer(ignored -> { }); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + assertThrows(CompletionException.class, + () -> reward.giveRewardAsync(user, options).toCompletableFuture().join()); + } + + assertFalse(injected.get()); + } + + @Test + void legacySchedulerRejectionFailsAsyncRewardClosed() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + when(vault.getEcon()).thenReturn(mock(Economy.class)); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Legacy"); + doThrow(new IllegalStateException("scheduler stopped")).when(scheduler) + .runTask(eq(plugin), any(Runnable.class)); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(2); + return null; + } + }); + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(realUser, new HashMap<>()).toCompletableFuture().join()); + } + } + + @Test + void schedulerRejectionReleasesUnstartedRandomActionForReplay() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("RandomReplay"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + AtomicReference amount = new AtomicReference<>(1D); + AtomicBoolean reject = new AtomicBoolean(true); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + if (reject.get()) throw new IllegalStateException("scheduler stopped"); + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(amount.get()); + return CompletableFuture.completedFuture(null); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + Reward.RewardReplayFailure checkpoint = findCheckpoint(assertThrows( + java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(realUser, new HashMap<>()).toCompletableFuture().join())); + + amount.set(2D); + reject.set(false); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertEquals(1, queued.size(), "the safely unstarted action is regenerated on retry"); + queued.remove(0).run(); + resumed.toCompletableFuture().join(); + } + verify(economy, never()).depositPlayer(offlinePlayer, 1D); + verify(economy).depositPlayer(offlinePlayer, 2D); + } + + @Test + void capturedAsyncContinuationActionCheckpointsRetryOnlyTheUnfinishedSuffix() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Checkpointed"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + CompletableFuture continuation = new CompletableFuture<>(); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(1); + realUser.giveMoney(2); + return null; + } + @Override public boolean supportsAsyncRequest() { return true; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + AdvancedCoreUser.AsyncActionContext context = ignoredUser.captureAsyncActionContext(); + return continuation.thenApply(context.wrap(nothing -> { + realUser.giveMoney(1); + realUser.giveMoney(2); + return null; + })); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage first = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + continuation.complete(null); + assertEquals(1, queued.size()); + queued.remove(0).run(); + assertEquals(1, queued.size()); + enabled.set(false); + queued.remove(0).run(); + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> first.toCompletableFuture().join()); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertTrue(checkpoint.getReplayPlaceholders().entrySet().stream().anyMatch(entry -> + entry.getKey().startsWith("__advancedcore_replay_legacy_actions_") && entry.getValue().startsWith("v2:"))); + assertTrue(checkpoint.getReplayPlaceholders().keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_legacy_actions_") && key.endsWith("_snapshot"))); + + enabled.set(true); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertEquals(1, queued.size(), "the completed first action is never scheduled again"); + queued.remove(0).run(); + resumed.toCompletableFuture().join(); + } + verify(economy).depositPlayer(offlinePlayer, 1); + verify(economy).depositPlayer(offlinePlayer, 2); + } + + @Test + void legacyActionSnapshotsSurviveReorderRemovalAndExtension() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("ActionSnapshot"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + AtomicReference> amounts = new AtomicReference<>(new ArrayList<>(List.of(1D, 2D))); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + for (double amount : amounts.get()) realUser.giveMoney(amount); + return CompletableFuture.completedFuture(null); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage first = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + queued.remove(0).run(); + enabled.set(false); + queued.remove(0).run(); + Reward.RewardReplayFailure checkpoint = findCheckpoint(assertThrows( + java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join())); + + enabled.set(true); + amounts.set(new ArrayList<>(List.of(2D, 3D))); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertEquals(1, queued.size(), "the completed, removed action is not rescheduled"); + queued.remove(0).run(); + assertEquals(1, queued.size(), "the extended action runs after the unfinished stable action"); + queued.remove(0).run(); + resumed.toCompletableFuture().join(); + } + verify(economy).depositPlayer(offlinePlayer, 1D); + verify(economy).depositPlayer(offlinePlayer, 2D); + verify(economy).depositPlayer(offlinePlayer, 3D); + } + + @Test + void legacyActionReplayAllowsRemovalOfAConclusiveUnstartedAction() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("RemovedUnfinishedAction"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + AtomicReference> amounts = new AtomicReference<>(new ArrayList<>(List.of(1D, 2D))); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + for (double amount : amounts.get()) realUser.giveMoney(amount); + return CompletableFuture.completedFuture(null); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage first = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + queued.remove(0).run(); + enabled.set(false); + queued.remove(0).run(); + Reward.RewardReplayFailure checkpoint = findCheckpoint(assertThrows( + java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join())); + + enabled.set(true); + amounts.set(new ArrayList<>(List.of(1D))); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + resumed.toCompletableFuture().join(); + assertTrue(queued.isEmpty(), "an action proven unstarted may disappear before retry"); + } + verify(economy).depositPlayer(offlinePlayer, 1D); + verify(economy, never()).depositPlayer(offlinePlayer, 2D); + } + + @Test + void itemActionFingerprintIsCanonicalAndIncludesMetadata() throws Exception { + LinkedHashMap first = new LinkedHashMap<>(); + first.put("type", "DIAMOND"); + first.put("amount", 1); + first.put("meta", Map.of("display-name", "Original", "lore", List.of("one", "two"))); + LinkedHashMap reordered = new LinkedHashMap<>(); + reordered.put("meta", Map.of("lore", List.of("one", "two"), "display-name", "Original")); + reordered.put("amount", 1); + reordered.put("type", "DIAMOND"); + LinkedHashMap changedMetadata = new LinkedHashMap<>(reordered); + changedMetadata.put("meta", Map.of("display-name", "Changed", "lore", List.of("one", "two"))); + java.lang.reflect.Method descriptor = AdvancedCoreUser.class.getDeclaredMethod("canonicalActionDescriptor", + Object.class); + descriptor.setAccessible(true); + String firstDescriptor = (String) descriptor.invoke(null, first); + String reorderedDescriptor = (String) descriptor.invoke(null, reordered); + String changedDescriptor = (String) descriptor.invoke(null, changedMetadata); + + assertEquals(Reward.legacyActionFingerprint(firstDescriptor), Reward.legacyActionFingerprint(reorderedDescriptor)); + assertNotEquals(Reward.legacyActionFingerprint(firstDescriptor), Reward.legacyActionFingerprint(changedDescriptor)); + } + + @Test + void legacyQueuedActionFailsWhenPluginShutsDownBeforeExecution() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + when(vault.getEcon()).thenReturn(mock(Economy.class)); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Legacy"); + AtomicReference queued = new AtomicReference<>(); + doAnswer(invocation -> { + queued.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(2); + return null; + } + }); + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + assertFalse(result.toCompletableFuture().isDone()); + enabled.set(false); + queued.get().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + } + } + + @Test + void legacyActionsCapturedByAsyncContextAreAwaited() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Legacy"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + CompletableFuture continuation = new CompletableFuture<>(); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + AdvancedCoreUser.AsyncActionContext context = ignoredUser.captureAsyncActionContext(); + return continuation.thenApply(context.wrap(nothing -> { + realUser.giveMoney(2); + return null; + })); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + continuation.complete(null); + assertEquals(1, queued.size()); + assertFalse(result.toCompletableFuture().isDone()); + queued.get(0).run(); + result.toCompletableFuture().join(); + } + verify(economy).depositPlayer(offlinePlayer, 2); + } + + @Test + void emptyLegacyActionCollectionDoesNotCreateItsOwnCheckpoint() { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + AtomicInteger checkpoints = new AtomicInteger(); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(ignored -> checkpoints.incrementAndGet()); + HashMap placeholders = new HashMap<>(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + + AdvancedCoreUser.AsyncActionCollection collection = realUser.beginAsyncActionCollection( + Reward.replayStateFor(options), placeholders, "AsyncReward/0"); + realUser.endAsyncActionCollection(collection).toCompletableFuture().join(); + + assertEquals(0, checkpoints.get()); + assertFalse(placeholders.keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_legacy_actions_"))); + } + + @Test + void temporaryPermissionIsGrantedOnceAcrossReplayOfItsInjection() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + when(plugin.isEnabled()).thenReturn(true); + com.bencodez.advancedcore.api.permissions.PermissionHandler permissions = + mock(com.bencodez.advancedcore.api.permissions.PermissionHandler.class); + when(plugin.getPermissionHandler()).thenReturn(permissions); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(ignored -> { }); + Reward.ReplayState replayState = Reward.replayStateFor(options); + HashMap placeholders = new HashMap<>(); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + for (int attempt = 0; attempt < 2; attempt++) { + AdvancedCoreUser.AsyncActionCollection collection = realUser.beginAsyncActionCollection( + replayState, placeholders, "AsyncReward/TempPermission"); + realUser.addPermission("advancedcore.replay", 90); + realUser.endAsyncActionCollection(collection).toCompletableFuture().join(); + } + } + + verify(permissions).addPermission(player, "advancedcore.replay", 90); + } + + @Test + void serializedPersistedReplaysDoNotClaimUnscopedContinuationActions() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Serialized"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queuedActions = new ArrayList<>(); + doAnswer(invocation -> { + queuedActions.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + ArrayList> continuations = new ArrayList<>(); + continuations.add(new CompletableFuture<>()); + continuations.add(new CompletableFuture<>()); + AtomicInteger invocations = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + int index = invocations.getAndIncrement(); + return continuations.get(index).thenApply(nothing -> { + realUser.giveMoney(index + 1); + return null; + }); + } + }); + + java.lang.reflect.Method enqueue = AdvancedCoreUser.class.getDeclaredMethod("enqueuePersistedReplay", + java.util.function.Supplier.class); + enqueue.setAccessible(true); + java.util.function.Supplier> replay = + () -> reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + enqueue.invoke(realUser, replay); + enqueue.invoke(realUser, replay); + assertEquals(1, invocations.get(), "the second replay must remain behind the first continuation"); + + continuations.get(0).complete(null); + assertEquals(1, queuedActions.size()); + assertEquals(2, invocations.get(), "the second replay starts after the first async stage completes"); + queuedActions.get(0).run(); + + continuations.get(1).complete(null); + assertEquals(2, queuedActions.size()); + queuedActions.get(1).run(); + } + verify(economy).depositPlayer(offlinePlayer, 1); + verify(economy).depositPlayer(offlinePlayer, 2); + } + + @Test + void unscopedActionsAreNotClaimedByAnArbitraryCollection() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + when(vault.getEcon()).thenReturn(mock(Economy.class)); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Nested"); + ArrayList queuedActions = new ArrayList<>(); + doAnswer(invocation -> { + queuedActions.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(mock(OfflinePlayer.class)); + AdvancedCoreUser.AsyncActionCollection outer = realUser.beginAsyncActionCollection(); + AdvancedCoreUser.AsyncActionCollection inner = realUser.beginAsyncActionCollection(); + realUser.restoreAsyncActionCollectionScope(inner); + realUser.restoreAsyncActionCollectionScope(outer); + realUser.giveMoney(1); + realUser.claimAsyncContinuationActions(inner); + + CompletionStage outerCompletion = realUser.endAsyncActionCollection(outer); + CompletionStage innerCompletion = realUser.endAsyncActionCollection(inner); + assertEquals(1, queuedActions.size()); + assertTrue(outerCompletion.toCompletableFuture().isDone()); + assertTrue(innerCompletion.toCompletableFuture().isDone()); + queuedActions.get(0).run(); + outerCompletion.toCompletableFuture().join(); + innerCompletion.toCompletableFuture().join(); + } + } + + @Test + void synchronousRewardActionIsNotClaimedByPendingAsyncInjection() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + when(vault.getEcon()).thenReturn(mock(Economy.class)); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Independent"); + ArrayList queuedActions = new ArrayList<>(); + doAnswer(invocation -> { + queuedActions.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + CompletableFuture pending = new CompletableFuture<>(); + handler.getInjectedRewards().add(new RewardInject("Async") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return pending; } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + realUser.giveMoney(4); + assertEquals(1, queuedActions.size()); + pending.complete(null); + assertTrue(result.toCompletableFuture().isDone(), + "an unrelated queued action must not become this async reward's dependency"); + } + } + + @Test + void failedLegacyActionCreatedByAnAsyncContinuationDoesNotFailAnUnrelatedReward() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + when(vault.getEcon()).thenReturn(mock(Economy.class)); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Legacy"); + AtomicReference queued = new AtomicReference<>(); + doAnswer(invocation -> { + queued.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + CompletableFuture continuation = new CompletableFuture<>(); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return continuation.thenApply(nothing -> { + realUser.giveMoney(2); + return null; + }); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + continuation.complete(null); + enabled.set(false); + queued.get().run(); + result.toCompletableFuture().join(); + } + } + + @Test + void concurrentContinuationsDoNotClaimOneAnotherActions() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Concurrent"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + AtomicReference successfulAction = new AtomicReference<>(); + doThrow(new IllegalStateException("first scheduler failure")).doAnswer(invocation -> { + successfulAction.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + ArrayList> continuations = new ArrayList<>(); + continuations.add(new CompletableFuture<>()); + continuations.add(new CompletableFuture<>()); + AtomicInteger invocation = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + int index = invocation.getAndIncrement(); + return continuations.get(index).thenApply(nothing -> { + realUser.giveMoney(index + 1); + return null; + }); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage first = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + CompletionStage second = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + + continuations.get(0).complete(null); + first.toCompletableFuture().join(); + assertFalse(second.toCompletableFuture().isDone()); + + continuations.get(1).complete(null); + successfulAction.get().run(); + second.toCompletableFuture().join(); + } + verify(economy).depositPlayer(offlinePlayer, 2); + } + + @Test + void commandSnapshotIsCheckpointedBeforeItsFirstDispatch() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + List checkpoints = new ArrayList<>(); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, (java.util.function.Consumer) checkpoints::add); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("replayCommandSequence", + AdvancedCorePlugin.class, HashMap.class, String.class, List.class, List.class, stateType, String.class, + java.util.function.BiFunction.class); + replay.setAccessible(true); + List dispatched = new ArrayList<>(); + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(null, plugin, new HashMap<>(), "console", + List.of("say %value%"), List.of("say fixed"), replayState, "AsyncReward/0", + (java.util.function.BiFunction>) (command, index) -> { + dispatched.add(command); + return CompletableFuture.completedFuture(null); + }); + + ArgumentCaptor writes = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(writes.capture()); + assertTrue(dispatched.isEmpty()); + assertFalse(result.toCompletableFuture().isDone()); + writes.getValue().run(); + assertEquals(List.of("say fixed"), dispatched); + assertTrue(checkpoints.get(0).getReplayProgress().isEmpty()); + assertTrue(checkpoints.get(0).getPlaceholders().entrySet().stream() + .anyMatch(entry -> entry.getKey().startsWith("__advancedcore_replay_commands_") + && entry.getKey().endsWith("_snapshot") && entry.getValue().contains("c2F5IGZpeGVk"))); + ArgumentCaptor progressWrite = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor, times(2)).execute(progressWrite.capture()); + progressWrite.getAllValues().get(1).run(); + result.toCompletableFuture().join(); + } + + @Test + void commandSnapshotDistinguishesAnEmptyElementFromAnEmptyList() throws Exception { + java.lang.reflect.Method encode = Reward.class.getDeclaredMethod("encodeCommandSnapshot", List.class); + encode.setAccessible(true); + String emptyList = (String) encode.invoke(null, List.of()); + String emptyElement = (String) encode.invoke(null, List.of("")); + assertNotEquals(emptyList, emptyElement); + java.lang.reflect.Method decode = Reward.class.getDeclaredMethod("decodeCommandSnapshot", String.class, int.class); + decode.setAccessible(true); + assertEquals(List.of(), decode.invoke(null, emptyList, 0)); + assertEquals(List.of(""), decode.invoke(null, emptyElement, 1)); + } + + @Test + void asyncInjectionsAreSequentialAndPostRewardsWaitForThem() { + List events = new ArrayList<>(); + HashMap placeholders = new HashMap<>(); + CompletableFuture deferred = new CompletableFuture<>(); + + RewardInject first = new RewardInject("First") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, + HashMap ignoredPlaceholders) { + throw new AssertionError("the asynchronous hook should be used"); + } + + @Override + public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("first-start"); + return deferred; + } + }; + first.asPlaceholder("first"); + RewardInject second = new RewardInject("Second") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, + HashMap ignoredPlaceholders) { + throw new AssertionError("the asynchronous hook should be used"); + } + + @Override + public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("second:" + ignoredPlaceholders.get("first")); + return CompletableFuture.completedFuture("second-value"); + } + }; + second.asPlaceholder("second"); + RewardInject post = new RewardInject("Post") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, + HashMap ignoredPlaceholders) { + events.add("post:" + ignoredPlaceholders.get("second")); + return null; + } + }; + post.postReward(); + handler.getInjectedRewards().add(first); + handler.getInjectedRewards().add(second); + handler.getInjectedRewards().add(post); + + CompletionStage result = reward.giveInjectedRewardsAsync(user, placeholders); + assertFalse(result.toCompletableFuture().isDone()); + assertEquals(List.of("first-start"), events); + + deferred.complete("first-value"); + result.toCompletableFuture().join(); + assertEquals(List.of("first-start", "second:first-value", "post:second-value"), events); + assertEquals("first-value", placeholders.get("first")); + assertEquals("second-value", placeholders.get("second")); + } + + @Test + void checkpointWriteRunsOffServerThreadAndGatesTheNextInjection() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + List events = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("First") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("first"); + return null; + } + }); + handler.getInjectedRewards().add(new RewardInject("Second") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("second"); + return null; + } + }); + + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(null, null, false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, (java.util.function.Consumer) checkpoint -> + events.add("checkpoint:" + checkpoint.getReplayProgress().get("AsyncReward"))); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + ArgumentCaptor writes = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(writes.capture()); + assertEquals(List.of("first"), events); + assertFalse(result.toCompletableFuture().isDone()); + + writes.getValue().run(); + verify(storageExecutor, times(2)).execute(writes.capture()); + assertEquals(List.of("first", "checkpoint:1", "second"), events); + writes.getAllValues().get(writes.getAllValues().size() - 1).run(); + result.toCompletableFuture().join(); + assertEquals(List.of("first", "checkpoint:1", "second", "checkpoint:2"), events); + } + + @Test + void injectionIsNotifiedOnlyAfterItsReplayCheckpointIsPersisted() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + List events = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("Points") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("injection"); + return null; + } + @Override public CompletionStage onReplayCheckpointPersisted(Reward ignored, AdvancedCoreUser ignoredUser, + String occurrenceId, String injectionKey) { + events.add("ack:" + occurrenceId + ":" + injectionKey); + return CompletableFuture.completedFuture(null); + } + }); + + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(null, null, false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, (java.util.function.Consumer) checkpoint -> + events.add("checkpoint")); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence-1"); + + ArgumentCaptor writes = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(writes.capture()); + assertEquals(List.of("injection"), events); + assertFalse(result.toCompletableFuture().isDone()); + + writes.getValue().run(); + result.toCompletableFuture().join(); + assertEquals(List.of("injection", "checkpoint", "ack:occurrence-1:AsyncReward/0"), events); + } + + @Test + void timedOutQueuedCheckpointCannotPersistLater() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + AtomicInteger writes = new AtomicInteger(); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(null, null, false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) checkpoint -> writes.incrementAndGet()); + java.lang.reflect.Method persist = stateType.getDeclaredMethod("persistCheckpointAsync", + AdvancedCorePlugin.class, HashMap.class, long.class, TimeUnit.class); + persist.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) persist.invoke(replayState, plugin, new HashMap<>(), 0L, + TimeUnit.MILLISECONDS); + + ArgumentCaptor queued = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(queued.capture()); + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + queued.getValue().run(); + assertEquals(0, writes.get()); + } + + @Test + void replayRetriesFailedCheckpointNotificationWithoutRepeatingInjection() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + AtomicInteger injections = new AtomicInteger(); + AtomicInteger notifications = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Points") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + injections.incrementAndGet(); + return null; + } + @Override public CompletionStage onReplayCheckpointPersisted(Reward ignored, + AdvancedCoreUser ignoredUser, String occurrenceId, String injectionKey) { + return notifications.incrementAndGet() == 1 + ? CompletableFuture.failedFuture(new IllegalStateException("ack unavailable")) + : CompletableFuture.completedFuture(null); + } + }); + + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(null, null, false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, (java.util.function.Consumer) checkpoint -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage first = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence-1"); + assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join()); + @SuppressWarnings("unchecked") + CompletionStage retry = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence-1"); + retry.toCompletableFuture().join(); + + assertEquals(1, injections.get()); + assertEquals(2, notifications.get()); + } + + @Test + void typedIntegerAsyncHookUsesParsedValue() { + data.set("Amount", 7); + HashMap placeholders = new HashMap<>(); + List received = new ArrayList<>(); + + RewardInjectInt amount = new RewardInjectInt("Amount") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public String onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, int ignoredValue, + HashMap ignoredPlaceholders) { + throw new AssertionError("the asynchronous typed hook should be used"); + } + + @Override + public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, int value, + HashMap ignoredPlaceholders) { + received.add(value); + return CompletableFuture.completedFuture("async-" + value); + } + }; + amount.asPlaceholder("amount"); + handler.getInjectedRewards().add(amount); + + reward.giveInjectedRewardsAsync(user, placeholders).toCompletableFuture().join(); + assertTrue(received.contains(7)); + assertEquals("async-7", placeholders.get("amount")); + } + + @Test + void typedStringAsyncHookPreservesConfiguredValueWhenCallbackReturnsNull() { + data.set("Message", "configured-value"); + HashMap placeholders = new HashMap<>(); + RewardInjectString message = new RewardInjectString("Message") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public String onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, String value, + HashMap ignoredPlaceholders) { return null; } + }; + message.asPlaceholder("message"); + handler.getInjectedRewards().add(message); + + reward.giveInjectedRewardsAsync(user, placeholders).toCompletableFuture().join(); + + assertEquals("configured-value", placeholders.get("message")); + } + + @Test + void failedAsyncInjectionStopsDependentRewardsAndPropagates() { + AtomicBoolean dependentRan = new AtomicBoolean(); + RewardInject failing = asyncInjection("Failure", CompletableFuture.failedFuture( + new IllegalStateException("durable update failed")), null); + RewardInject dependent = asyncInjection("Dependent", CompletableFuture.completedFuture(null), dependentRan); + handler.getInjectedRewards().add(failing); + handler.getInjectedRewards().add(dependent); + + CompletionStage result = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertFalse(dependentRan.get()); + } + + @Test + void failingLegacyInjectionDoesNotStopLaterAsyncChainSteps() { + AtomicBoolean laterRan = new AtomicBoolean(); + handler.getInjectedRewards().add(new RewardInject("BrokenLegacy") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + throw new IllegalStateException("bad legacy configuration"); + } + }); + handler.getInjectedRewards().add(asyncInjection("Later", CompletableFuture.completedFuture(null), laterRan)); + + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + assertTrue(laterRan.get()); + } + + @Test + void failingLegacyInjectionRetainsPersistedReplay() throws Exception { + AtomicBoolean laterRan = new AtomicBoolean(); + handler.getInjectedRewards().add(new RewardInject("PlayerBoundLegacy") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + throw new NullPointerException("player disconnected"); + } + }); + handler.getInjectedRewards().add(asyncInjection("Later", CompletableFuture.completedFuture(null), laterRan)); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + Object replayState = state.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertFalse(laterRan.get()); + } + + @Test + void silentPlayerBoundLegacyInjectionRetainsPersistedReplay() throws Exception { + AtomicBoolean invoked = new AtomicBoolean(); + data.set("Sound", "configured"); + RewardInject playerOutput = new RewardInject("Sound") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invoked.set(true); + return null; + } + }.requiresPlayer(); + handler.getInjectedRewards().add(playerOutput); + AdvancedCoreUser disconnected = mock(AdvancedCoreUser.class); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + Object replayState = state.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, disconnected, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertFalse(invoked.get()); + } + + @Test + void absentPlayerBoundInjectionDoesNotBlockOfflineSafeReplay() throws Exception { + AtomicBoolean playerOutputRan = new AtomicBoolean(); + AtomicBoolean offlineSafeOutputRan = new AtomicBoolean(); + when(plugin.getTimer()).thenReturn(handler.getDelayedTimer()); + handler.getInjectedRewards().add(new RewardInjectString("Sound") { + @Override + public String onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, String ignoredValue, + HashMap ignoredPlaceholders) { + playerOutputRan.set(true); + return null; + } + }.requiresPlayer()); + handler.getInjectedRewards().add( + asyncInjection("OfflineSafe", CompletableFuture.completedFuture(null), offlineSafeOutputRan)); + AdvancedCoreUser disconnected = mock(AdvancedCoreUser.class); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + Object replayState = state.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, disconnected, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + result.toCompletableFuture().join(); + assertFalse(playerOutputRan.get()); + assertTrue(offlineSafeOutputRan.get()); + } + + @Test + void disabledPlayerBoundInjectionDoesNotBlockOfflineSafeReplay() throws Exception { + AtomicBoolean offlineSafeOutputRan = new AtomicBoolean(); + when(plugin.getTimer()).thenReturn(handler.getDelayedTimer()); + data.set("Sound.Enabled", false); + RewardSound.register(handler, plugin); + handler.getInjectedRewards().add( + asyncInjection("OfflineSafe", CompletableFuture.completedFuture(null), offlineSafeOutputRan)); + AdvancedCoreUser disconnected = mock(AdvancedCoreUser.class); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + Object replayState = state.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, disconnected, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + result.toCompletableFuture().join(); + assertTrue(offlineSafeOutputRan.get()); + } + + @Test + void synchronizedAsyncInjectionSerializesThroughCompletion() { + List> completions = new ArrayList<>(); + List starts = new ArrayList<>(); + RewardInject serialized = new RewardInject("Serialized") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + starts.add(starts.size() + 1); + CompletableFuture completion = new CompletableFuture<>(); + completions.add(completion); + return completion; + } + }; + serialized.synchronize(); + handler.getInjectedRewards().add(serialized); + + CompletionStage first = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + CompletionStage second = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + assertEquals(List.of(1), starts); + + completions.get(0).complete(null); + assertEquals(List.of(1, 2), starts); + completions.get(1).complete(null); + first.toCompletableFuture().join(); + second.toCompletableFuture().join(); + } + + @Test + void shutdownStopsContinuationsAfterPendingInjection() { + CompletableFuture completion = new CompletableFuture<>(); + AtomicBoolean dependentRan = new AtomicBoolean(); + handler.getInjectedRewards().add(asyncInjection("Pending", completion, null)); + handler.getInjectedRewards().add( + asyncInjection("Dependent", CompletableFuture.completedFuture(null), dependentRan)); + CompletionStage result = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + + enabled.set(false); + completion.complete(null); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertFalse(dependentRan.get()); + } + + @Test + void schedulerCancellationCompletesStageExceptionally() { + Reward shortTimeoutReward = new Reward("AsyncReward", data) { + @Override + protected long getServerThreadDispatchTimeoutMillis() { + return 25; + } + }; + try { + java.lang.reflect.Field pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(shortTimeoutReward, plugin); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + AtomicBoolean invoked = new AtomicBoolean(); + handler.getInjectedRewards().add( + asyncInjection("Dropped", CompletableFuture.completedFuture(null), invoked)); + org.mockito.Mockito.reset(scheduler); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + AtomicReference droppedTask = new AtomicReference<>(); + doAnswer(invocation -> { + droppedTask.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + + CompletionStage result = shortTimeoutReward.giveInjectedRewardsAsync(user, new HashMap<>()); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + droppedTask.get().run(); + assertFalse(invoked.get()); + } + + @Test + void injectionCompletionIsNotLimitedBySchedulerHandoffTimeout() throws Exception { + Reward shortTimeoutReward = new Reward("AsyncReward", data) { + @Override + protected long getServerThreadDispatchTimeoutMillis() { + return 25; + } + }; + try { + java.lang.reflect.Field pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(shortTimeoutReward, plugin); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + CompletableFuture completion = new CompletableFuture<>(); + handler.getInjectedRewards().add(asyncInjection("SlowStorage", completion, null)); + + CompletionStage result = shortTimeoutReward.giveInjectedRewardsAsync(user, new HashMap<>()); + Thread.sleep(75); + assertFalse(result.toCompletableFuture().isDone()); + completion.complete(null); + result.toCompletableFuture().join(); + } + + @Test + void nestedPostRewardWaitsForChildRewardBeforeAdvancing() { + handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); + when(plugin.getRewardHandler()).thenReturn(handler); + when(user.getPlugin()).thenReturn(plugin); + data.createSection("Rewards"); + CompletableFuture child = new CompletableFuture<>(); + doReturn(child).when(handler).giveRewardAsync(eq(user), any(ConfigurationSection.class), eq("Rewards"), + any()); + List events = new ArrayList<>(); + RewardSubRewards.register(handler, plugin); + RewardInject after = new RewardInject("After") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("after-child"); + return null; + } + }; + after.postReward(); + handler.getInjectedRewards().add(after); + + CompletionStage result = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + assertFalse(result.toCompletableFuture().isDone()); + assertTrue(events.isEmpty()); + + child.complete(null); + result.toCompletableFuture().join(); + assertEquals(List.of("after-child"), events); + } + + @Test + void removedSubRewardsSectionCannotDropItsPendingNestedSnapshot() throws Exception { + RewardSubRewards.register(handler, plugin); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(new HashMap<>(), new HashMap<>(), false); + @SuppressWarnings("unchecked") + CompletionStage> snapshot = Reward.replayNestedRewardSnapshot(plugin, new HashMap<>(), + "nested-list:Rewards", List.of("First", "Second"), (Reward.ReplayState) replayState, + "AsyncReward/0"); + snapshot.toCompletableFuture().join(); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + } + + @Test + void completedNestedChildIsSkippedBeforeRegistryResolutionOnRetry() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance(new HashMap<>(), + new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + List checkpoints = new ArrayList<>(); + setConsumer.invoke(replayState, + (java.util.function.Consumer) checkpoints::add); + HashMap placeholders = new HashMap<>(); + List firstAttempt = new ArrayList<>(); + + CompletionStage failed = Reward.replayNestedRewardSequence(plugin, placeholders, + "nested-list:Rewards", List.of("removed", "remaining"), replayState, "AsyncReward/0", + (rewardName, index) -> { + firstAttempt.add(rewardName); + return index == 0 ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(new IllegalStateException("later child failed")); + }); + assertThrows(java.util.concurrent.CompletionException.class, () -> failed.toCompletableFuture().join()); + assertEquals(List.of("removed", "remaining"), firstAttempt); + assertFalse(checkpoints.isEmpty()); + + List retried = new ArrayList<>(); + Reward.replayNestedRewardSequence(plugin, placeholders, "nested-list:Rewards", List.of(), replayState, + "AsyncReward/0", (rewardName, index) -> { + retried.add(rewardName); + return CompletableFuture.completedFuture(null); + }).toCompletableFuture().join(); + assertEquals(List.of("remaining"), retried); + } + + @Test + void completedSingleNestedRewardIsNotResolvedAgainAfterParentCheckpointFailure() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance( + Map.of("AsyncReward", 0), Map.of("AsyncReward", "registry"), false); + List checkpoints = new ArrayList<>(); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) checkpoints::add); + AtomicInteger dispatches = new AtomicInteger(); + HashMap placeholders = new HashMap<>(); + + CompletionStage first = Reward.replaySingleNestedReward(plugin, placeholders, "selected", + replayState, "AsyncReward/0", () -> { + dispatches.incrementAndGet(); + return CompletableFuture.completedFuture(null); + }); + ArgumentCaptor writes = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(writes.capture()); + assertFalse(first.toCompletableFuture().isDone()); + writes.getValue().run(); + first.toCompletableFuture().join(); + assertEquals(1, checkpoints.size()); + + Reward.ReplayState restored = (Reward.ReplayState) constructor.newInstance( + checkpoints.get(0).getReplayProgress(), checkpoints.get(0).getReplayRegistryFingerprints(), false); + Reward.replaySingleNestedReward(plugin, checkpoints.get(0).getPlaceholders(), "selected", restored, + "AsyncReward/0", () -> { + dispatches.incrementAndGet(); + return CompletableFuture.failedFuture(new IllegalStateException("selected reward was removed")); + }).toCompletableFuture().join(); + + assertEquals(1, dispatches.get(), "completed child must be skipped before it is resolved again"); + } + + @Test + void copiedNestedPlaceholdersMergeSingleChildCompletionIntoParentState() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance( + new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + HashMap parentPlaceholders = new HashMap<>(); + HashMap childPlaceholders = new HashMap<>(parentPlaceholders); + + Reward.replaySingleNestedReward(plugin, childPlaceholders, "selected", replayState, "AsyncReward/0", + () -> CompletableFuture.completedFuture(null)).toCompletableFuture().join(); + replayState.mergeReplayMetadataInto(parentPlaceholders); + + assertTrue(parentPlaceholders.keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_single_child_"))); + } + + @Test + void completedPlayerCommandSnapshotDoesNotRequirePlayerAgain() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance( + new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + HashMap placeholders = new HashMap<>(); + java.lang.reflect.Method commandReplay = Reward.class.getDeclaredMethod("replayCommandSequence", + AdvancedCorePlugin.class, HashMap.class, String.class, List.class, stateType, String.class, + java.util.function.BiFunction.class); + commandReplay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage completedCommands = (CompletionStage) commandReplay.invoke(null, plugin, + placeholders, "player", List.of("already-ran"), replayState, "AsyncReward/1", + (java.util.function.BiFunction>) (command, index) -> + CompletableFuture.completedFuture(null)); + completedCommands.toCompletableFuture().join(); + data.set("Commands.Player", new ArrayList<>(List.of("changed-after-checkpoint"))); + RewardCommands.registerCommands(handler, plugin); + when(user.preformCommandAsync(any(ArrayList.class), eq(placeholders))).thenAnswer(invocation -> + Reward.replayCommandSequence(plugin, placeholders, "player", + invocation.getArgument(0), (command, index) -> CompletableFuture.failedFuture( + new AssertionError("completed player command must not run again")))); + + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, user, placeholders, 0, + replayState, "AsyncReward", "occurrence"); + result.toCompletableFuture().join(); + + verify(user, never()).validatePlayerCommandAvailabilityAsync(); + } + + @Test + void missingConfigurationSectionIsAllowedAfterItsSingleChildCompleted() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance( + new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + HashMap placeholders = new HashMap<>(); + Reward.replaySingleNestedReward(plugin, placeholders, "selected", replayState, "AsyncReward/0", + () -> CompletableFuture.completedFuture(null)).toCompletableFuture().join(); + + handler.getInjectedRewards().add(new com.bencodez.advancedcore.api.rewards.injected.RewardInjectConfigurationSection( + "RemovedSection") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override public boolean hasPendingReplayWork(HashMap ignored) { return true; } + @Override public String onRewardRequested(Reward ignoredReward, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredSection, HashMap ignoredPlaceholders) { + throw new AssertionError("completed missing section must not be resolved"); + } + }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, placeholders, 0, + replayState, "AsyncReward", "occurrence"); + + resumed.toCompletableFuture().join(); + } + + @Test + void missingConfigurationSectionIsAllowedAfterItsNestedListCompleted() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance( + new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + HashMap placeholders = new HashMap<>(); + Reward.replayNestedRewardSequence(plugin, placeholders, "nested-list:RemovedSection", + List.of("first", "second"), replayState, "AsyncReward/0", + (rewardName, index) -> CompletableFuture.completedFuture(null)).toCompletableFuture().join(); + + handler.getInjectedRewards().add(new com.bencodez.advancedcore.api.rewards.injected.RewardInjectConfigurationSection( + "RemovedSection") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override public boolean hasPendingReplayWork(HashMap currentPlaceholders) { + String lane = "nested-list:" + getPath(); + return Reward.hasReplayNestedRewardSnapshot(currentPlaceholders, lane) + && !Reward.hasCompletedNestedRewardSequence(currentPlaceholders, lane); + } + @Override public String onRewardRequested(Reward ignoredReward, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredSection, HashMap ignoredPlaceholders) { + throw new AssertionError("completed missing list section must not be resolved"); + } + }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, placeholders, 0, + replayState, "AsyncReward", "occurrence"); + + resumed.toCompletableFuture().join(); + } + + @Test + void everyNestedRewardInjectorWaitsForItsSelectedChild() { + handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); + when(plugin.getRewardHandler()).thenReturn(handler); + when(user.getPlugin()).thenReturn(plugin); + data.set("RandomReward", new ArrayList<>(List.of("child"))); + CompletableFuture child = new CompletableFuture<>(); + ArgumentCaptor childOptions = ArgumentCaptor.forClass(RewardOptions.class); + doReturn(child).when(handler).giveRewardAsync(eq(user), eq("child"), childOptions.capture()); + List events = new ArrayList<>(); + RewardRandomReward.register(handler, plugin); + handler.getInjectedRewards().add(new RewardInject("After") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("after-child"); + return null; + } + }.postReward()); + + CompletionStage result = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + assertFalse(result.toCompletableFuture().isDone()); + assertTrue(events.isEmpty()); + assertNotNull(childOptions.getValue().getAsyncReplayState()); + assertTrue(childOptions.getValue().getAsyncReplayKey().endsWith("/selected:child")); + child.complete(null); + result.toCompletableFuture().join(); + assertEquals(List.of("after-child"), events); + } + + @Test + void randomRewardResumesItsSelectedChildAfterConfigurationRemoval() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); + when(plugin.getRewardHandler()).thenReturn(handler); + when(user.getPlugin()).thenReturn(plugin); + data.set("RandomReward", new ArrayList<>(List.of("child"))); + doReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))) + .when(handler).giveRewardAsync(eq(user), eq("child"), any(RewardOptions.class)); + RewardRandomReward.register(handler, plugin); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + Object initialState = state.newInstance(new HashMap<>(), new HashMap<>(), false); + List checkpoints = new ArrayList<>(); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(initialState, + (java.util.function.Consumer) checkpoints::add); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + CompletionStage first = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + initialState, "AsyncReward", "occurrence"); + ArgumentCaptor writes = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(writes.capture()); + verify(handler, never()).giveRewardAsync(eq(user), eq("child"), any(RewardOptions.class)); + + writes.getValue().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join()); + assertEquals(1, checkpoints.size()); + assertTrue(checkpoints.get(0).getPlaceholders().keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_selection_"))); + data.set("RandomReward", null); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, + checkpoints.get(0).getPlaceholders(), 0, + state.newInstance(checkpoints.get(0).getReplayProgress(), + checkpoints.get(0).getReplayRegistryFingerprints(), false), + "AsyncReward", "occurrence"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + verify(handler, times(2)).giveRewardAsync(eq(user), eq("child"), any(RewardOptions.class)); + } + + @Test + void unclaimedChoiceIsAddedOnlyAfterItsSelectionIsDurable() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + data.set("EnableChoices", true); + when(user.getChoicePreference("AsyncReward")).thenReturn(""); + RewardChoices.register(handler, plugin); + List writes = new ArrayList<>(); + doAnswer(invocation -> { + writes.add(invocation.getArgument(0)); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor stateConstructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + stateConstructor.setAccessible(true); + Object replayState = stateConstructor.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward"); + + assertEquals(1, writes.size()); + verify(user, never()).addUnClaimedChoiceReward(eq("AsyncReward"), any(String.class)); + writes.get(0).run(); + verify(user).addUnClaimedChoiceReward(eq("AsyncReward"), any(String.class)); + assertEquals(2, writes.size()); + writes.get(1).run(); + result.toCompletableFuture().join(); + } + + @Test + void replaySideEffectOccurrenceDistinguishesRepeatedNestedChildren() { + try (org.mockito.MockedStatic replay = org.mockito.Mockito.mockStatic(Reward.class, + org.mockito.Mockito.CALLS_REAL_METHODS)) { + replay.when(Reward::currentReplayOccurrenceId).thenReturn("parent-occurrence"); + replay.when(Reward::currentReplayKey).thenReturn("parent/Choice:0/0"); + String first = Reward.currentReplaySideEffectOccurrenceId(); + replay.when(Reward::currentReplayKey).thenReturn("parent/Choice:1/0"); + String second = Reward.currentReplaySideEffectOccurrenceId(); + + assertNotEquals(first, second); + assertTrue(first.startsWith("parent-occurrence:")); + assertTrue(second.startsWith("parent-occurrence:")); + } + } + + @Test + void javascriptChildCarriesSelectionAndReplayStateIntoItsCheckpoint() { + ConfigurationSection javascript = data.createSection("Javascript"); + javascript.set("Enabled", true); + javascript.set("Expression", "true"); + javascript.createSection("TrueRewards"); + RewardJavascript.register(handler, plugin); + RewardOptions nestedOptions = new RewardOptions(); + + try (MockedConstruction engines = mockConstruction(JavascriptEngine.class, + org.mockito.Mockito.withSettings().defaultAnswer(Answers.RETURNS_SELF), + (engine, context) -> when(engine.getBooleanValue("true")).thenReturn(true)); + MockedConstruction builders = mockConstruction(RewardBuilder.class, + org.mockito.Mockito.withSettings().defaultAnswer(Answers.RETURNS_SELF), + (builder, context) -> { + when(builder.getRewardOptions()).thenReturn(nestedOptions); + when(builder.withPlaceHolder(any())).thenAnswer(invocation -> { + nestedOptions.withPlaceHolder(invocation.getArgument(0)); + return builder; + }); + when(builder.sendAsync(user)).thenReturn(CompletableFuture.completedFuture(null)); + })) { + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + } + + assertNotNull(nestedOptions.getAsyncReplayState()); + assertTrue(nestedOptions.getAsyncReplayKey().endsWith("/path:TrueRewards")); + assertTrue(nestedOptions.getPlaceholders().isEmpty(), + "fresh replay metadata must stay in the shared state, not caller placeholders"); + } + + @Test + void disabledJavascriptResumesItsPersistedSelectedChild() throws Exception { + ConfigurationSection javascript = data.createSection("Javascript"); + javascript.set("Enabled", true); + javascript.set("Expression", "true"); + javascript.createSection("TrueRewards"); + RewardJavascript.register(handler, plugin); + AtomicInteger sends = new AtomicInteger(); + + try (MockedConstruction engines = mockConstruction(JavascriptEngine.class, + org.mockito.Mockito.withSettings().defaultAnswer(Answers.RETURNS_SELF), + (engine, context) -> when(engine.getBooleanValue("true")).thenReturn(true)); + MockedConstruction builders = mockConstruction(RewardBuilder.class, + org.mockito.Mockito.withSettings().defaultAnswer(Answers.RETURNS_SELF), + (builder, context) -> { + RewardOptions nested = new RewardOptions(); + when(builder.getRewardOptions()).thenReturn(nested); + when(builder.withPlaceHolder(any())).thenAnswer(invocation -> { + nested.withPlaceHolder(invocation.getArgument(0)); + return builder; + }); + when(builder.sendAsync(user)).thenAnswer(ignored -> sends.getAndIncrement() == 0 + ? CompletableFuture.failedFuture(new IllegalStateException("temporary child failure")) + : CompletableFuture.completedFuture(null)); + })) { + Reward.RewardReplayFailure checkpoint = findCheckpoint(assertThrows(CompletionException.class, + () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join())); + javascript.set("Enabled", false); + + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + resumed.toCompletableFuture().join(); + + assertEquals(2, sends.get()); + assertEquals(1, engines.constructed().size(), "a persisted selection must not rerun JavaScript"); + } + } + + @Test + void specialChanceChildCarriesSelectionAndReplayStateIntoItsCheckpoint() { + ConfigurationSection specialChance = data.createSection("SpecialChance"); + specialChance.createSection("1"); + RewardSpecialChance.register(handler, plugin); + RewardOptions nestedOptions = new RewardOptions(); + + try (MockedConstruction builders = mockConstruction(RewardBuilder.class, + org.mockito.Mockito.withSettings().defaultAnswer(Answers.RETURNS_SELF), + (builder, context) -> { + when(builder.getRewardOptions()).thenReturn(nestedOptions); + when(builder.withPlaceHolder(any())).thenAnswer(invocation -> { + nestedOptions.withPlaceHolder(invocation.getArgument(0)); + return builder; + }); + when(builder.sendAsync(user)).thenReturn(CompletableFuture.completedFuture(null)); + })) { + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + } + + assertNotNull(nestedOptions.getAsyncReplayState()); + assertTrue(nestedOptions.getAsyncReplayKey().endsWith("/path:1")); + assertTrue(nestedOptions.getPlaceholders().isEmpty(), + "fresh replay metadata must stay in the shared state, not caller placeholders"); + } + + @Test + void persistedReplayFailsWhenItsPlayerDisappearsBeforePreparation() { + when(user.getPlayerName()).thenReturn("Departed"); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(ignored -> { }); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer("Departed")).thenReturn(null); + CompletionStage result = reward.giveRewardUserAsync(user, new HashMap<>(), options); + CompletionException failure = assertThrows(CompletionException.class, + () -> result.toCompletableFuture().join()); + assertTrue(failure.getCause().getMessage().contains("Player became unavailable")); + } + } + + @Test + void freshPersistedReplayUsesAwaitedLegacyInjectionChain() throws Exception { + Player player = mock(Player.class); + when(user.getPlayer()).thenReturn(player); + when(user.getPlayerName()).thenReturn("Queued"); + when(user.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(plugin.getTimer()).thenReturn(handler.getDelayedTimer()); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + AtomicBoolean injected = new AtomicBoolean(); + handler.getInjectedRewards().add(new RewardInject("LegacyItem") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + injected.set(true); + return null; + } + }); + CountDownLatch checkpointed = new CountDownLatch(1); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(ignored -> checkpointed.countDown()); + + reward.giveRewardUser(user, new HashMap<>(), options); + + assertTrue(checkpointed.await(2, TimeUnit.SECONDS)); + assertTrue(injected.get()); + } + + @Test + void replayCheckpointSkipsAlreadyAppliedInjectionAfterLaterFailure() throws Exception { + AtomicInteger alreadyApplied = new AtomicInteger(); + AtomicBoolean fail = new AtomicBoolean(true); + RewardInject applied = new RewardInject("Money") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + alreadyApplied.incrementAndGet(); + return "receipt-1"; + } + }; + applied.asPlaceholder("receipt"); + handler.getInjectedRewards().add(applied); + handler.getInjectedRewards().add(new RewardInject("Durable") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return fail.get() ? CompletableFuture.failedFuture(new IllegalStateException("temporary")) + : CompletableFuture.completedFuture(null); + } + }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class); + replay.setAccessible(true); + CompletionStage first = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0); + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> first.toCompletableFuture().join()); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertEquals(1, checkpoint.getCompletedInjectionCount()); + assertEquals("receipt-1", checkpoint.getReplayPlaceholders().get("receipt")); + fail.set(false); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), + checkpoint.getCompletedInjectionCount()); + resumed.toCompletableFuture().join(); + assertEquals(1, alreadyApplied.get()); + } + + @Test + void commandReplayRetriesOnlyTheFailedSuffix() throws Exception { + ArrayList dispatched = new ArrayList<>(); + AtomicBoolean failSecond = new AtomicBoolean(true); + AtomicInteger expansion = new AtomicInteger(); + AtomicReference> configuredTemplates = new AtomicReference<>( + List.of("first %value%", "second %value%")); + handler.getInjectedRewards().add(new RewardInject("Commands") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap commandPlaceholders) { + List templates = configuredTemplates.get(); + int currentExpansion = expansion.incrementAndGet(); + List expanded = templates.stream() + .map(template -> template.substring(0, template.indexOf(' ')) + "-" + currentExpansion).toList(); + return Reward.replayCommandSequence(plugin, commandPlaceholders, "console", templates, expanded, + (command, ignoredIndex) -> { + dispatched.add(command); + return command.startsWith("second-") && failSecond.get() + ? CompletableFuture.failedFuture(new IllegalStateException("temporary")) + : CompletableFuture.completedFuture(null); + }).thenApply(nothing -> null); + } + }); + + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join()); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + failSecond.set(false); + configuredTemplates.set(List.of("inserted %value%", "second %value%", "first %value%")); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, + user, checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + resumed.toCompletableFuture().join(); + + assertEquals(List.of("first-1", "second-1", "second-1"), dispatched); + } + + @Test + void replayProgressBeyondTheInjectorRegistryFailsClosed() throws Exception { + AtomicInteger invoked = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Only") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invoked.incrementAndGet(); + return null; + } + }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class); + replay.setAccessible(true); + CompletionStage result = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 2); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertEquals(0, invoked.get()); + } + + @Test + void persistedReplayRejectsAChangedInjectorRegistryBeforeAnyStageRuns() throws Exception { + AtomicInteger applied = new AtomicInteger(); + AtomicInteger inserted = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Applied") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + applied.incrementAndGet(); + return null; + } + }); + handler.getInjectedRewards().add(new RewardInject("Fails") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return CompletableFuture.failedFuture(new IllegalStateException("temporary")); + } + }); + + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join()); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertFalse(checkpoint.getReplayRegistryFingerprints().isEmpty()); + + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage matchingRegistry = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertThrows(java.util.concurrent.CompletionException.class, () -> matchingRegistry.toCompletableFuture().join()); + assertEquals(1, applied.get()); + + handler.getInjectedRewards().add(0, new RewardInject("Inserted") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + inserted.incrementAndGet(); + return null; + } + }); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + assertEquals(1, applied.get()); + assertEquals(0, inserted.get()); + } + + @Test + void completedInjectorIsNotReplayedWhenItsConfigurationChangesDuringRecovery() throws Exception { + AtomicInteger applied = new AtomicInteger(); + data.set("Applied", "before"); + handler.getInjectedRewards().add(new RewardInject("Applied") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + applied.incrementAndGet(); + return null; + } + }); + handler.getInjectedRewards().add(new RewardInject("Fails") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return CompletableFuture.failedFuture(new IllegalStateException("temporary")); + } + }); + + Reward.RewardReplayFailure checkpoint = findCheckpoint(assertThrows( + java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join())); + data.set("Applied", "after"); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + assertEquals(1, applied.get()); + } + + @Test + void legacyCountOnlyReplayCheckpointDoesNotResumeAgainstAnUnknownRegistry() throws Exception { + AtomicInteger invoked = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Applied") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invoked.incrementAndGet(); + return null; + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 1, + state.newInstance(Map.of(), Map.of(), true), "AsyncReward"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + assertEquals(0, invoked.get()); + } + + @Test + void persistedReplayDoesNotFallBackToSynchronousDispatchWhenAsyncInjectorsAreGone() { + AtomicInteger invoked = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Synchronous") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invoked.incrementAndGet(); + return null; + } + }); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayProgress(Map.of("AsyncReward", 1)); + options.setAsyncReplayRegistryFingerprints(Map.of("AsyncReward", "removed-async-injector")); + + reward.giveRewardUser(user, new HashMap<>(), options); + + assertEquals(0, invoked.get()); + } + + @Test + void publicRewardUserApiRetainsNullOptionsCompatibilityWithAsyncInjectors() { + AtomicReference receivedOptions = new AtomicReference<>(); + handler.getInjectedRewards().add(new RewardInject("Async") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean requiresConfiguredDataForAsync() { return false; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return CompletableFuture.completedFuture(null); + } + }); + Reward spyReward = org.mockito.Mockito.spy(reward); + doAnswer(invocation -> { + receivedOptions.set(invocation.getArgument(2)); + return CompletableFuture.completedFuture(null); + }).when(spyReward).giveRewardUserAsync(eq(user), any(HashMap.class), any(RewardOptions.class)); + + spyReward.giveRewardUser(user, new HashMap<>(), null); + + assertNotNull(receivedOptions.get()); + } + + @Test + void sharedNestedReplayStatePreventsSynchronousFallbackWhenOptionMapsAreEmpty() throws Exception { + AtomicInteger invoked = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Synchronous") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invoked.incrementAndGet(); + return null; + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayKey("AsyncReward/0/child"); + options.setAsyncReplayState((Reward.ReplayState) state.newInstance(Map.of("AsyncReward/0/child", 1), + Map.of("AsyncReward/0/child", "removed-async-injector"), false)); + assertTrue(options.getAsyncReplayProgress().isEmpty()); + assertTrue(options.getAsyncReplayRegistryFingerprints().isEmpty()); + + reward.giveRewardUser(user, new HashMap<>(), options); + + assertEquals(0, invoked.get()); + } + + @Test + void childCheckpointBindsItsUncompletedParentRegistryBeforeNestedDispatch() throws Exception { + AtomicBoolean dispatchingChild = new AtomicBoolean(); + AtomicInteger parentInvocations = new AtomicInteger(); + AtomicInteger insertedInvocations = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Nested") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward current, AdvancedCoreUser currentUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + if (dispatchingChild.get()) return CompletableFuture.completedFuture(null); + parentInvocations.incrementAndGet(); + dispatchingChild.set(true); + try { + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage child = (CompletionStage) replay.invoke(current, currentUser, new HashMap<>(), 0, + Reward.currentReplayState(), Reward.currentReplayKey() + "/child"); + return child.whenComplete((ignored, failure) -> dispatchingChild.set(false)).thenApply(ignored -> null); + } catch (ReflectiveOperationException failure) { + return CompletableFuture.failedFuture(failure); + } + } + }); + handler.getInjectedRewards().add(new RewardInject("ChildFailure") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return dispatchingChild.get() ? CompletableFuture.failedFuture(new IllegalStateException("temporary")) + : CompletableFuture.completedFuture(null); + } + }); + + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join()); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertTrue(checkpoint.getReplayRegistryFingerprints().containsKey("AsyncReward")); + assertTrue(checkpoint.getReplayRegistryFingerprints().containsKey("AsyncReward/0/child")); + + handler.getInjectedRewards().add(0, new RewardInject("Inserted") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + insertedInvocations.incrementAndGet(); + return null; + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + assertEquals(1, parentInvocations.get()); + assertEquals(0, insertedInvocations.get()); + } + + @Test + void replayProgressDoesNotSuppressASecondSameNamedChildOccurrence() throws Exception { + AtomicInteger deliveries = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Deliver") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + deliveries.incrementAndGet(); + return CompletableFuture.completedFuture(null); + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(java.util.Map.class); + constructor.setAccessible(true); + Object state = constructor.newInstance((Object) null); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + ((CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, state, + "root/Rewards/child:0")).toCompletableFuture().join(); + ((CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, state, + "root/Rewards/child:1")).toCompletableFuture().join(); + assertEquals(2, deliveries.get()); + } + + @Test + void replaySelectionKeepsTheFirstNondeterministicChoice() { + HashMap placeholders = new HashMap<>(); + AtomicInteger selections = new AtomicInteger(); + assertEquals("first", Reward.replaySelection(placeholders, + () -> selections.incrementAndGet() == 1 ? "first" : "second")); + assertEquals("first", Reward.replaySelection(placeholders, + () -> selections.incrementAndGet() == 1 ? "first" : "second")); + assertEquals(1, selections.get()); + } + + @Test + void independentAsyncRewardOccurrencesReceiveDifferentDurableIds() { + List occurrences = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("Capture") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + occurrences.add(Reward.currentReplayOccurrenceId()); + return CompletableFuture.completedFuture(null); + } + }); + + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + + assertEquals(2, occurrences.size()); + assertNotEquals(occurrences.get(0), occurrences.get(1)); + } + + @Test + void retryOfTheSameOccurrenceRetainsItsId() throws Exception { + List occurrences = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("Capture") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + occurrences.add(Reward.currentReplayOccurrenceId()); + return CompletableFuture.completedFuture(null); + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + for (int attempt = 0; attempt < 2; attempt++) { + ((CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, state.newInstance((Object) null), + "AsyncReward", "occurrence-1")).toCompletableFuture().join(); + } + + assertEquals(List.of("occurrence-1", "occurrence-1"), occurrences); + } + + @Test + void nestedAsyncInjectorCanAwaitTheSameSharedInjectorWithoutDeadlocking() { + AtomicInteger invocations = new AtomicInteger(); + RewardInject nested = new RewardInject("Nested") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean supportsAsyncSynchronization() { return false; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward current, AdvancedCoreUser currentUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + if (invocations.incrementAndGet() == 1) { + return current.giveInjectedRewardsAsync(currentUser, new HashMap<>()).thenApply(ignored -> null); + } + return CompletableFuture.completedFuture(null); + } + }; + nested.synchronize(); + handler.getInjectedRewards().add(nested); + + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + assertEquals(2, invocations.get()); + } + + private RewardInject asyncInjection(String path, CompletionStage completion, AtomicBoolean invoked) { + return new RewardInject(path) { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (invoked != null) invoked.set(true); + return completion; + } + }; + } + + private Reward.RewardReplayFailure findCheckpoint(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof Reward.RewardReplayFailure) return (Reward.RewardReplayFailure) current; + } + throw new AssertionError("missing replay checkpoint", failure); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 52926d2b3c..2b5fb0a16c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -12,11 +13,16 @@ import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; @@ -56,9 +62,19 @@ public void setUp() { handler = mock(RewardHandler.class); user = mock(AdvancedCoreUser.class); executor = new RewardExecutor(handler, plugin); + BukkitScheduler scheduler = mock(BukkitScheduler.class); when(plugin.getLogger()).thenReturn(mock(Logger.class)); when(plugin.isEnabled()).thenReturn(true); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(org.bukkit.entity.Entity.class)); when(user.getPlayerName()).thenReturn("Ben"); when(user.getUUID()).thenReturn("uuid"); when(user.isOnline()).thenReturn(true); @@ -124,6 +140,248 @@ public void listRewardDispatchesEveryNamedReward() { assertTrue(options.isOnlineSet()); } + @Test + public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("Child", "Child"))); + Reward child = mock(Reward.class); + when(handler.getReward("Child")).thenReturn(child); + when(child.giveRewardAsync(eq(user), any(RewardOptions.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + + try (MockedStatic replay = mockStatic(Reward.class, + org.mockito.Mockito.CALLS_REAL_METHODS)) { + replay.when(Reward::currentReplayOccurrenceId).thenReturn("parent-occurrence"); + executor.giveRewardAsync(user, data, "Rewards", new RewardOptions()).toCompletableFuture().join(); + } + + ArgumentCaptor captured = ArgumentCaptor.forClass(RewardOptions.class); + verify(child, org.mockito.Mockito.times(2)).giveRewardAsync(eq(user), captured.capture()); + List children = captured.getAllValues(); + assertFalse(children.get(0) == children.get(1)); + assertFalse(children.get(0).getAsyncReplayKey().equals(children.get(1).getAsyncReplayKey())); + assertSame(children.get(0).getAsyncReplayState(), children.get(1).getAsyncReplayState()); + assertEquals("parent-occurrence", children.get(0).getAsyncReplayOccurrenceId()); + assertEquals("parent-occurrence", children.get(1).getAsyncReplayOccurrenceId()); + } + + @Test + public void asyncRewardSetupLeavesThePrimaryThreadAndRetainsCompletion() { + Reward child = mock(Reward.class); + CompletableFuture childResult = new CompletableFuture<>(); + when(child.giveRewardAsync(eq(user), any(RewardOptions.class))).thenReturn(childResult); + RewardOptions options = new RewardOptions(); + Reward.ReplayState replayState = Reward.replayStateFor(new RewardOptions()); + BukkitScheduler scheduler = plugin.getBukkitScheduler(); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic replay = mockStatic(Reward.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + replay.when(Reward::currentReplayState).thenReturn(replayState); + replay.when(Reward::currentReplayKey).thenReturn("parent"); + replay.when(Reward::currentReplayOccurrenceId).thenReturn("occurrence"); + when(child.getRewardName()).thenReturn("child"); + CompletionStage result = executor.giveRewardAsync(user, child, options); + + verify(scheduler).runTaskAsynchronously(eq(plugin), task.capture()); + verify(child, never()).giveRewardAsync(eq(user), any(RewardOptions.class)); + assertFalse(result.toCompletableFuture().isDone()); + task.getValue().run(); + verify(child).giveRewardAsync(eq(user), any(RewardOptions.class)); + assertSame(replayState, options.getAsyncReplayState()); + assertEquals("parent/child", options.getAsyncReplayKey()); + assertEquals("occurrence", options.getAsyncReplayOccurrenceId()); + assertFalse(result.toCompletableFuture().isDone()); + childResult.completeExceptionally(new IllegalStateException("child failed")); + assertThrows(CompletionException.class, () -> result.toCompletableFuture().join()); + } + } + + @Test + public void asyncRewardSetupTimesOutWhenTheAcceptedTaskNeverStarts() { + Reward child = mock(Reward.class); + BukkitScheduler scheduler = plugin.getBukkitScheduler(); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + RewardExecutor bounded = new RewardExecutor(handler, plugin) { + @Override + protected long getPrimaryThreadHandoffTimeoutMillis() { + return 10L; + } + }; + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + CompletionStage result = bounded.giveRewardAsync(user, child, new RewardOptions()); + verify(scheduler).runTaskAsynchronously(eq(plugin), task.capture()); + assertThrows(CompletionException.class, () -> result.toCompletableFuture().join()); + task.getValue().run(); + verify(child, never()).giveRewardAsync(eq(user), any(RewardOptions.class)); + } + } + + @Test + public void asyncListLazilyClonesAndSharesReplayMetadataThroughState() { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("First", "Second"))); + Reward first = mock(Reward.class); + Reward second = mock(Reward.class); + when(handler.getReward("First")).thenReturn(first); + when(handler.getReward("Second")).thenReturn(second); + RewardOptions parent = new RewardOptions().addPlaceholder("ordinary", "parent"); + when(first.giveRewardAsync(eq(user), any(RewardOptions.class))).thenAnswer(invocation -> { + RewardOptions child = invocation.getArgument(1); + child.getPlaceholders().put("ordinary", "child"); + child.getAsyncReplayState().recordReplayMetadata("__advancedcore_replay_commands_test_snapshot", "v1:c2"); + return CompletableFuture.completedFuture(null); + }); + when(second.giveRewardAsync(eq(user), any(RewardOptions.class))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("later child failed"))); + + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Rewards", parent).toCompletableFuture().join()); + + ArgumentCaptor captured = ArgumentCaptor.forClass(RewardOptions.class); + verify(second).giveRewardAsync(eq(user), captured.capture()); + assertEquals("parent", captured.getValue().getPlaceholders().get("ordinary")); + assertEquals("v1:c2", captured.getValue().getAsyncReplayState() + .replayMetadata("__advancedcore_replay_commands_test_snapshot")); + assertFalse(captured.getValue().getPlaceholders() + .containsKey("__advancedcore_replay_commands_test_snapshot")); + } + + @Test + public void asyncListReplayDoesNotRepeatCompletedDirectCommands() { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("/first", "/second"))); + MiscUtils misc = mock(MiscUtils.class); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("/first"), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("/second"), any())) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary")), + CompletableFuture.completedFuture(null)); + AtomicReference checkpoint = new AtomicReference<>(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0).run(); + return null; + }).when(timer).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(timer); + RewardOptions first = new RewardOptions(); + first.setAsyncReplayCheckpointConsumer(checkpoint::set); + + try (MockedStatic miscStatic = mockStatic(MiscUtils.class)) { + miscStatic.when(MiscUtils::getInstance).thenReturn(misc); + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Rewards", first).toCompletableFuture().join()); + assertNotNull(checkpoint.get()); + data.set("Rewards", "/inserted"); + + RewardOptions retry = new RewardOptions() + .setPlaceholders(new java.util.HashMap<>(checkpoint.get().getPlaceholders())); + retry.setAsyncReplayCheckpointConsumer(ignored -> { }); + executor.giveRewardAsync(user, data, "Rewards", retry).toCompletableFuture().join(); + } + + verify(misc).executeConsoleCommandsAsync(eq("Ben"), eq("/first"), any()); + verify(misc, org.mockito.Mockito.times(2)).executeConsoleCommandsAsync(eq("Ben"), eq("/second"), any()); + verify(misc, never()).executeConsoleCommandsAsync(eq("Ben"), eq("/inserted"), any()); + } + + @Test + public void reusedFreshOptionsDoNotRetainCommandReplayCursor() throws Exception { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("/once-per-send"))); + MiscUtils misc = mock(MiscUtils.class); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("/once-per-send"), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + RewardOptions reused = new RewardOptions(); + for (String fieldName : List.of("ACTIVE_REPLAY_STATE", "ACTIVE_REPLAY_KEY", "ACTIVE_REPLAY_OCCURRENCE_ID")) { + java.lang.reflect.Field field = Reward.class.getDeclaredField(fieldName); + field.setAccessible(true); + ((ThreadLocal) field.get(null)).remove(); + } + + try (MockedStatic miscStatic = mockStatic(MiscUtils.class)) { + miscStatic.when(MiscUtils::getInstance).thenReturn(misc); + executor.giveRewardAsync(user, data, "Rewards", reused).toCompletableFuture().join(); + executor.giveRewardAsync(user, data, "Rewards", reused).toCompletableFuture().join(); + } + + verify(misc, org.mockito.Mockito.times(2)) + .executeConsoleCommandsAsync(eq("Ben"), eq("/once-per-send"), any()); + assertTrue(reused.getPlaceholders().keySet().stream() + .noneMatch(key -> key.startsWith("__advancedcore_replay_"))); + } + + @Test + public void snapshottedListFailsWhenANamedChildDisappears() { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("First", "Missing"))); + Reward firstReward = mock(Reward.class); + Reward missingReward = mock(Reward.class); + when(firstReward.giveRewardAsync(eq(user), any(RewardOptions.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + when(missingReward.giveRewardAsync(eq(user), any(RewardOptions.class))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); + when(handler.getReward("First")).thenReturn(firstReward); + when(handler.getReward("Missing")).thenReturn(missingReward, (Reward) null); + AtomicReference checkpoint = new AtomicReference<>(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0).run(); + return null; + }).when(timer).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(timer); + RewardOptions first = new RewardOptions(); + first.setAsyncReplayCheckpointConsumer(checkpoint::set); + + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Rewards", first).toCompletableFuture().join()); + assertNotNull(checkpoint.get()); + RewardOptions retry = new RewardOptions() + .setPlaceholders(new java.util.HashMap<>(checkpoint.get().getPlaceholders())); + retry.setAsyncReplayCheckpointConsumer(ignored -> { }); + + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Rewards", retry).toCompletableFuture().join()); + verify(missingReward).giveRewardAsync(eq(user), any(RewardOptions.class)); + } + + @Test + public void freshAsyncListSkipsUnknownChildAndContinues() { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("Missing", "Valid"))); + Reward valid = mock(Reward.class); + when(handler.getReward("Valid")).thenReturn(valid); + when(valid.giveRewardAsync(eq(user), any(RewardOptions.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + + executor.giveRewardAsync(user, data, "Rewards", new RewardOptions()).toCompletableFuture().join(); + + verify(valid).giveRewardAsync(eq(user), any(RewardOptions.class)); + } + + @Test + public void freshMissingConfigurationPathIsANoOp() { + YamlConfiguration data = new YamlConfiguration(); + + executor.giveRewardAsync(user, data, "Optional.Fallback", new RewardOptions()) + .toCompletableFuture().join(); + } + + @Test + public void inheritedDurableStateFailsWhenSelectedRewardDisappears() { + RewardOptions queued = new RewardOptions(); + queued.setAsyncReplayCheckpointConsumer(ignored -> { }); + Reward.ReplayState durableState = Reward.replayStateFor(queued); + RewardOptions child = new RewardOptions(); + child.setAsyncReplayState(durableState); + + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, "Missing", child).toCompletableFuture().join()); + } + @Test public void stringRewardDispatchesNamedReward() { YamlConfiguration data = new YamlConfiguration(); @@ -196,13 +454,17 @@ public void inlineSectionConstructsChecksAndExecutesReward() { public void commandStyleRewardExecutesConsoleCommand() { MiscUtils misc = mock(MiscUtils.class); RewardOptions options = new RewardOptions().addPlaceholder("player", "Ben"); + when(misc.executeConsoleCommandsAsync("Ben", "/say hi", options.getPlaceholders())) + .thenReturn(CompletableFuture.completedFuture(null)); try (MockedStatic miscStatic = mockStatic(MiscUtils.class)) { miscStatic.when(MiscUtils::getInstance).thenReturn(misc); executor.giveReward(user, "/say hi", options); + executor.giveRewardAsync(user, "/say hi", options).toCompletableFuture().join(); } verify(misc).executeConsoleCommands("Ben", "/say hi", options.getPlaceholders()); + verify(misc).executeConsoleCommandsAsync("Ben", "/say hi", options.getPlaceholders()); } @Test @@ -237,6 +499,50 @@ public void asyncRewardExecutionRunsImmediately() { verify(reward).giveReward(user, options); } + @Test + public void disabledPluginFailsEveryConfiguredAsyncDispatchWithoutExecuting() { + Reward reward = mock(Reward.class); + RewardOptions options = new RewardOptions(); + YamlConfiguration data = new YamlConfiguration(); + data.set("Reward", "Daily"); + when(handler.getReward("Daily")).thenReturn(reward); + when(plugin.isEnabled()).thenReturn(false); + + assertThrows(java.util.concurrent.CompletionException.class, + () -> executor.giveRewardAsync(user, reward, options).toCompletableFuture().join()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> executor.giveRewardAsync(user, "Daily", options).toCompletableFuture().join()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Reward", options).toCompletableFuture().join()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> executor.givePersistedQueueRewardAsync(user, "Daily", options).toCompletableFuture().join()); + + verify(reward, never()).giveRewardAsync(eq(user), any(RewardOptions.class)); + } + + @Test + public void unresolvedPersistedRewardFailsSoTheQueueCanRetainIt() { + when(handler.rewardExist("Missing")).thenReturn(false); + when(handler.hasDirectRewardHandle("Missing")).thenReturn(false); + when(handler.getQueuedGeneratedReward("Missing", user.getUUID())).thenReturn(null); + when(handler.getReward("Missing")).thenReturn(null); + + assertThrows(CompletionException.class, + () -> executor.givePersistedQueueRewardAsync(user, "Missing", new RewardOptions()) + .toCompletableFuture().join()); + } + + @Test + public void unresolvedNestedConfigurationFailsSoReplayCannotDropIt() { + YamlConfiguration data = new YamlConfiguration(); + RewardOptions replay = new RewardOptions(); + replay.setAsyncReplayCheckpointConsumer(ignored -> { }); + + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Removed", replay) + .toCompletableFuture().join()); + } + @Test public void choicesDispatchThroughRewardBuilder() { Reward mainReward = mock(Reward.class); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 780107790d..7288548ddd 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -2,21 +2,42 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.List; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; @@ -34,6 +55,7 @@ public class AdvancedCoreUserTest { private UserDataManager dataManager; private UserData data; private RewardHandler rewardHandler; + private ScheduledExecutorService delayedTimer; private AdvancedCoreUser user; @BeforeEach @@ -43,6 +65,7 @@ public void setUp() { dataManager = mock(UserDataManager.class); data = mock(UserData.class); rewardHandler = mock(RewardHandler.class); + delayedTimer = mock(ScheduledExecutorService.class); MySQL mysql = mock(MySQL.class); AdvancedCoreConfigOptions configOptions = mock(AdvancedCoreConfigOptions.class); @@ -52,7 +75,9 @@ public void setUp() { when(userManager.getDataManager()).thenReturn(dataManager); when(plugin.getUserManager()).thenReturn(userManager); when(plugin.getRewardHandler()).thenReturn(rewardHandler); + when(rewardHandler.getDelayedTimer()).thenReturn(delayedTimer); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); when(userManager.getOfflineRewardsPath()).thenReturn("offlineRewardsPath"); when(configOptions.isProcessRewards()).thenReturn(true); @@ -60,17 +85,121 @@ public void setUp() { user.setData(data); // Inject the mocked UserData object } + @Test + void unclaimedChoiceOccurrenceIsIdempotentButDistinctOccurrencesRemainClaimable() { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("UnClaimedChoices", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("UnClaimedChoices"), any()); + + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-two"); + + assertEquals(List.of("ChoiceReward", "ChoiceReward"), user.getUnClaimedChoices()); + assertEquals(2, stored.get().size()); + assertTrue(stored.get().stream().noneMatch("ChoiceReward"::equals)); + } + + @Test + void claimingOneOccurrencePreservesAnotherIdenticalChoiceReward() { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("UnClaimedChoices", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("UnClaimedChoices"), any()); + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-two"); + user.checkpointUnClaimedChoiceReward("occurrence-one"); + user.checkpointUnClaimedChoiceReward("occurrence-two"); + + user.removeUnClaimedChoiceReward("ChoiceReward"); + + assertEquals(List.of("ChoiceReward"), user.getUnClaimedChoices()); + assertEquals(1, stored.get().size()); + } + + @Test + void claimedChoiceRemainsDeduplicatedUntilOwningCheckpointPersists() { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("UnClaimedChoices", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("UnClaimedChoices"), any()); + + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-two"); + user.removeUnClaimedChoiceReward("ChoiceReward"); + assertEquals(List.of("ChoiceReward"), user.getUnClaimedChoices()); + user.removeUnClaimedChoiceReward("ChoiceReward"); + assertTrue(user.getUnClaimedChoices().isEmpty()); + + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + assertTrue(user.getUnClaimedChoices().isEmpty()); + assertEquals(2, stored.get().size()); + + user.checkpointUnClaimedChoiceReward("occurrence-one"); + user.checkpointUnClaimedChoiceReward("occurrence-two"); + assertTrue(stored.get().isEmpty()); + } + + @Test + void choiceClaimAfterOwningCheckpointRemovesOccurrenceImmediately() { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("UnClaimedChoices", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("UnClaimedChoices"), any()); + + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + user.checkpointUnClaimedChoiceReward("occurrence-one"); + assertEquals(List.of("ChoiceReward"), user.getUnClaimedChoices()); + + user.removeUnClaimedChoiceReward("ChoiceReward"); + assertTrue(stored.get().isEmpty()); + } + + @Test + void legacyChoiceAdditionPreservesModernOccurrenceIdentity() { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("UnClaimedChoices", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("UnClaimedChoices"), any()); + + user.addUnClaimedChoiceReward("Modern", "occurrence-one"); + String encodedModern = stored.get().get(0); + user.addUnClaimedChoiceReward("Legacy"); + user.addUnClaimedChoiceReward("Modern", "occurrence-one"); + + assertEquals(List.of("Modern", "Legacy"), user.getUnClaimedChoices()); + assertEquals(encodedModern, stored.get().get(0)); + assertEquals(2, stored.get().size()); + } + @Test void checkOfflineRewards_preservesServerRequirementForNormalReplay() { ArrayList rewards = new ArrayList<>(); rewards.add("VoteReward%placeholders%Server%pair%server-a"); when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); - user.checkOfflineRewards(); - ArgumentCaptor referenceCaptor = ArgumentCaptor.forClass(PersistedQueueReference.class); ArgumentCaptor optionsCaptor = ArgumentCaptor.forClass(RewardOptions.class); - verify(rewardHandler).givePersistedQueueReward(eq(user), referenceCaptor.capture(), optionsCaptor.capture()); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + user.checkOfflineRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), referenceCaptor.capture(), optionsCaptor.capture()); assertEquals("VoteReward", referenceCaptor.getValue().getReference()); RewardOptions options = optionsCaptor.getValue(); assertFalse(options.isForceOffline()); @@ -85,11 +214,12 @@ void forceRunOfflineRewards_stillBypassesNormalReplayChecks() { rewards.add("VoteReward%placeholders%Server%pair%server-a"); when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); - user.forceRunOfflineRewards(); - ArgumentCaptor referenceCaptor = ArgumentCaptor.forClass(PersistedQueueReference.class); ArgumentCaptor optionsCaptor = ArgumentCaptor.forClass(RewardOptions.class); - verify(rewardHandler).givePersistedQueueReward(eq(user), referenceCaptor.capture(), optionsCaptor.capture()); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + user.forceRunOfflineRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), referenceCaptor.capture(), optionsCaptor.capture()); assertEquals("VoteReward", referenceCaptor.getValue().getReference()); RewardOptions options = optionsCaptor.getValue(); assertTrue(options.isForceOffline()); @@ -98,6 +228,190 @@ void forceRunOfflineRewards_stillBypassesNormalReplayChecks() { assertEquals("server-a", options.getPlaceholders().get("Server")); } + @Test + void queuedAsyncReplayResumesAtItsStoredCheckpoint() { + ArrayList rewards = new ArrayList<>(); + rewards.add("VoteReward%asyncprogress%2%placeholders%Server%pair%server-a"); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + + ArgumentCaptor reference = ArgumentCaptor.forClass(PersistedQueueReference.class); + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkOfflineRewards(); + + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), reference.capture(), options.capture()); + assertEquals("VoteReward", reference.getValue().getReference()); + assertEquals(2, options.getValue().getCompletedAsyncInjections()); + } + + @Test + void queuedOccurrenceIsForwardedBeforeAnyAsyncInjectorRuns() { + String occurrence = UUID.randomUUID().toString(); + ArrayList rewards = new ArrayList<>(); + rewards.add("VoteReward%asyncoccurrence%" + occurrence + "%placeholders%Server%pair%server-a"); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkOfflineRewards(); + + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), options.capture()); + assertEquals(occurrence, options.getValue().getAsyncReplayOccurrenceId()); + } + + @Test + void legacyQueuedOccurrenceIsPersistedBeforeAnyAsyncInjectorRuns() { + ArrayList persisted = new ArrayList<>( + List.of("VoteReward%placeholders%Server%pair%server-a")); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any(), eq(false)); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(new CompletableFuture<>()); + + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkOfflineRewards(); + + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), options.capture()); + String occurrence = options.getValue().getAsyncReplayOccurrenceId(); + assertTrue(occurrence != null && !occurrence.isEmpty()); + assertTrue(persisted.get(0).contains("%asyncoccurrence%" + occurrence)); + } + + @Test + void deferredReplayRetainsOccurrenceAndCheckpoint() { + ArrayList persisted = new ArrayList<>(); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any()); + Reward reward = mock(Reward.class); + when(reward.getRewardName()).thenReturn("VoteReward"); + RewardOptions options = new RewardOptions().addPlaceholder("Server", "server-a"); + String occurrence = UUID.randomUUID().toString(); + options.setAsyncReplayOccurrenceId(occurrence); + options.setAsyncReplayProgress(Map.of("root/Grant:0", 1)); + options.setAsyncReplayRegistryFingerprints(Map.of("root/Grant:0", "fingerprint")); + + user.addOfflineRewards(reward, options.getPlaceholders(), options); + + assertEquals(1, persisted.size()); + assertTrue(persisted.get(0).contains("%asyncoccurrence%" + occurrence)); + assertTrue(persisted.get(0).contains("%asyncprogress%v3-")); + assertTrue(persisted.get(0).contains("Server%pair%server-a")); + } + + @Test + void deferredReplayAtomicallyReplacesItsExistingOccurrence() { + String occurrence = UUID.randomUUID().toString(); + ArrayList persisted = new ArrayList<>(List.of( + "old%asyncoccurrence%" + occurrence + "%placeholders%Server%pair%old", + "duplicate%asyncoccurrence%" + occurrence + "%placeholders%Server%pair%stale")); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any()); + Reward reward = mock(Reward.class); + when(reward.getRewardName()).thenReturn("VoteReward"); + RewardOptions options = new RewardOptions().addPlaceholder("Server", "server-a"); + options.setAsyncReplayOccurrenceId(occurrence); + + user.addOfflineRewards(reward, options.getPlaceholders(), options); + + assertEquals(1, persisted.size()); + assertTrue(persisted.get(0).contains("%asyncoccurrence%" + occurrence)); + assertTrue(persisted.get(0).contains("Server%pair%server-a")); + } + + @Test + void independentlyDeferredNestedChildrenKeepDistinctQueueEntries() { + ArrayList persisted = new ArrayList<>(); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any()); + Reward reward = mock(Reward.class); + when(reward.getRewardName()).thenReturn("ChildReward"); + Reward.ReplayState replayState = Reward.replayStateFor(new RewardOptions()); + RewardOptions first = Reward.withReplayState(new RewardOptions(), replayState, + "Parent/AdvancedRewards", "First:0", "parent-occurrence"); + RewardOptions second = Reward.withReplayState(new RewardOptions(), replayState, + "Parent/AdvancedRewards", "Second:1", "parent-occurrence"); + + user.addOfflineRewards(reward, first.getPlaceholders(), first); + user.addOfflineRewards(reward, second.getPlaceholders(), second); + + assertEquals(2, persisted.size()); + assertNotEquals(first.getAsyncReplayOccurrenceId(), second.getAsyncReplayOccurrenceId()); + assertTrue(persisted.get(0).contains("%asyncoccurrence%" + first.getAsyncReplayOccurrenceId())); + assertTrue(persisted.get(1).contains("%asyncoccurrence%" + second.getAsyncReplayOccurrenceId())); + + RewardOptions durableRoot = new RewardOptions(); + durableRoot.setAsyncReplayCheckpointConsumer(ignored -> { }); + Reward.ReplayState durableState = Reward.replayStateFor(durableRoot); + assertEquals("parent-occurrence", Reward.withReplayState(new RewardOptions(), durableState, + "Parent/AdvancedRewards", "First:0", "parent-occurrence").getAsyncReplayOccurrenceId()); + } + + @Test + void replayClaimCleanupRechecksSharedStateUnderItsLock() throws Exception { + java.lang.reflect.Method replayClaims = AdvancedCoreUser.class.getDeclaredMethod("replayClaims"); + replayClaims.setAccessible(true); + Object claims = replayClaims.invoke(user); + java.lang.reflect.Field lockField = AdvancedCoreUser.class.getDeclaredField("REPLAY_CLAIMS_LOCK"); + lockField.setAccessible(true); + Object lock = lockField.get(null); + java.lang.reflect.Field offlineField = claims.getClass().getDeclaredField("offline"); + offlineField.setAccessible(true); + @SuppressWarnings("unchecked") + Map offline = (Map) offlineField.get(claims); + AtomicReference failure = new AtomicReference<>(); + Thread cleanup; + synchronized (lock) { + cleanup = new Thread(() -> { + try { + java.lang.reflect.Method release = AdvancedCoreUser.class + .getDeclaredMethod("releaseReplayClaimsIfEmpty", claims.getClass()); + release.setAccessible(true); + release.invoke(user, claims); + } catch (Throwable error) { + failure.set(error); + } + }, "replay-claim-cleanup-test"); + cleanup.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (cleanup.getState() != Thread.State.BLOCKED && System.nanoTime() < deadline) { + Thread.yield(); + } + assertEquals(Thread.State.BLOCKED, cleanup.getState()); + offline.put("new-claim", 1); + } + cleanup.join(TimeUnit.SECONDS.toMillis(5)); + assertFalse(cleanup.isAlive()); + assertTrue(failure.get() == null, () -> "cleanup failed: " + failure.get()); + assertSame(claims, replayClaims.invoke(user)); + offline.clear(); + java.lang.reflect.Method release = AdvancedCoreUser.class + .getDeclaredMethod("releaseReplayClaimsIfEmpty", claims.getClass()); + release.setAccessible(true); + release.invoke(user, claims); + } + @Test void setOfflineRewards_emptyList() { ArrayList rewards = new ArrayList<>(); @@ -143,4 +457,479 @@ void setOfflineRewards_withinLimit() { user.setOfflineRewards(rewards); verify(data).setStringList("offlineRewardsPath", rewards); } + + @Test + void durableCheckpointTrimmingPreservesTheActiveEntry() throws Exception { + String active = "active-" + "a".repeat(70_000); + ArrayList rewards = new ArrayList<>(List.of(active, "old-reward")); + java.lang.reflect.Method write = AdvancedCoreUser.class.getDeclaredMethod( + "setOfflineRewards", ArrayList.class, boolean.class, String.class); + write.setAccessible(true); + + write.invoke(user, rewards, false, active); + + assertEquals(List.of(active), rewards); + verify(data).setStringList("offlineRewardsPath", rewards, false); + } + + @Test + void queueTrimmingPreservesEveryClaimedBacklogOccurrence() throws Exception { + String claimed = "claimed%asyncoccurrence%" + UUID.randomUUID(); + String oversizedUnclaimed = "unclaimed-" + "x".repeat(70_000); + ArrayList rewards = new ArrayList<>(List.of(claimed, oversizedUnclaimed)); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenReturn(new ArrayList<>(rewards)); + java.lang.reflect.Method claim = AdvancedCoreUser.class.getDeclaredMethod("claimOfflineReward", String.class); + claim.setAccessible(true); + + assertTrue((boolean) claim.invoke(user, claimed)); + user.setOfflineRewards(rewards); + + assertEquals(List.of(claimed), rewards); + verify(data).setStringList("offlineRewardsPath", rewards); + } + + @Test + void queueTrimmingRetainsClaimedCountOfIdenticalLegacyEntries() throws Exception { + String legacy = "legacy-" + "x".repeat(30_000); + ArrayList rewards = new ArrayList<>(List.of(legacy, legacy, legacy)); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenReturn(new ArrayList<>(rewards)); + java.lang.reflect.Method claim = AdvancedCoreUser.class.getDeclaredMethod("claimOfflineReward", String.class); + claim.setAccessible(true); + + assertTrue((boolean) claim.invoke(user, legacy)); + assertTrue((boolean) claim.invoke(user, legacy)); + user.setOfflineRewards(rewards); + + assertEquals(List.of(legacy, legacy), rewards); + verify(data).setStringList("offlineRewardsPath", rewards); + } + + @Test + void failedAsyncOfflineReplayIsRestoredForRetry() { + ArrayList initial = new ArrayList<>(); + initial.add("VoteReward%placeholders%Server%pair%server-a"); + initial.add("VoteReward%placeholders%Server%pair%server-a"); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(new ArrayList<>(initial)); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); + + user.checkOfflineRewards(); + + ArgumentCaptor> saved = ArgumentCaptor.forClass(ArrayList.class); + verify(data, org.mockito.Mockito.atLeastOnce()).setStringList(eq("offlineRewardsPath"), saved.capture()); + assertTrue(saved.getAllValues().stream().allMatch(value -> value.size() == initial.size())); + } + + @Test + void identicalOfflineRedeferralSurvivesClaimCompletion() throws Exception { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any()); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any(), eq(false)); + when(plugin.getOptions().isPauseRewards()).thenReturn(true); + when(rewardHandler.getPlaceholders()).thenReturn(new CopyOnWriteArrayList<>()); + when(rewardHandler.getInjectedRequirements()).thenReturn(new CopyOnWriteArrayList<>()); + Reward queuedReward = new Reward("VoteReward", new YamlConfiguration().createSection("Reward")); + java.lang.reflect.Field pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(queuedReward, plugin); + RewardOptions queuedOptions = new RewardOptions(); + queuedOptions.setAsyncReplayOccurrenceId(UUID.randomUUID().toString()); + queuedOptions.addPlaceholder("ExecDate", "123"); + queuedOptions.addPlaceholder("date", "stored-date"); + user.addOfflineRewards(queuedReward, queuedOptions.getPlaceholders(), queuedOptions); + String original = stored.get().get(0); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenAnswer(invocation -> queuedReward.giveRewardAsync(user, + invocation.getArgument(2, RewardOptions.class))); + PluginManager pluginManager = mock(PluginManager.class); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + user.checkOfflineRewards(); + } + + assertEquals(List.of(original), stored.get()); + } + + @Test + void failedAsyncTimedReplayIsRestoredAfterDueEntriesAreSaved() { + long due = System.currentTimeMillis() - 1_000; + String entry = "VoteReward%extime%1%placeholders%Server%pair%server-a"; + ArrayList timed = new ArrayList<>(); + timed.add(entry + "%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(timed)); + org.mockito.Mockito.doAnswer(invocation -> { + timed.clear(); + timed.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any(), eq(false)); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); + + user.checkDelayedTimedRewards(); + + ArgumentCaptor> saved = ArgumentCaptor.forClass(ArrayList.class); + verify(data).setStringList(eq("TimedRewards"), saved.capture()); + String restored = saved.getValue().get(0); + assertTrue(restored.contains("%asyncretry%1%")); + assertTrue(restored.contains("VoteReward%extime%1")); + verify(delayedTimer).schedule(any(Runnable.class), org.mockito.ArgumentMatchers.anyLong(), + eq(TimeUnit.MILLISECONDS)); + } + + @Test + void failedTimedReplayReconcilesCachedSameOccurrenceBeforeRestoring() { + long due = System.currentTimeMillis() - 1_000; + String occurrence = UUID.randomUUID().toString(); + String replaying = "VoteReward%asyncoccurrence%" + occurrence + "%placeholders%Server%pair%old"; + String cached = "VoteReward%asyncoccurrence%" + occurrence + + "%asyncprogress%2%asyncretry%3%placeholders%Server%pair%cached"; + ArrayList timed = new ArrayList<>(List.of( + replaying + "%ExecutionTime/%" + due, + cached + "%ExecutionTime/%" + (System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1)))); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(timed)); + org.mockito.Mockito.doAnswer(invocation -> { + timed.clear(); + timed.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any()); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); + + user.checkDelayedTimedRewards(); + + assertEquals(1, timed.size()); + assertTrue(timed.get(0).contains("%asyncoccurrence%" + occurrence)); + assertTrue(timed.get(0).contains("%asyncprogress%2%")); + assertTrue(timed.get(0).contains("%asyncretry%4%")); + assertTrue(timed.get(0).contains("Server%pair%cached")); + } + + @Test + void pendingAsyncOfflineReplayRemainsDurableAndIsNotDispatchedTwice() { + ArrayList rewards = new ArrayList<>(); + rewards.add("VoteReward%placeholders%Server%pair%server-a"); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(pending); + + user.checkOfflineRewards(); + user.checkOfflineRewards(); + + verify(rewardHandler, org.mockito.Mockito.times(1)).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + verify(data, org.mockito.Mockito.never()).setStringList(eq("offlineRewardsPath"), any()); + pending.complete(null); + assertTrue(rewards.isEmpty()); + verify(data).setStringList(eq("offlineRewardsPath"), any()); + } + + @Test + void pendingOfflineReplayClaimIsSharedAcrossWrappersForTheSameUuid() { + ArrayList rewards = new ArrayList<>(List.of("VoteReward")); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(any(AdvancedCoreUser.class), + any(PersistedQueueReference.class), any(RewardOptions.class))).thenReturn(pending); + AdvancedCoreUser second = new AdvancedCoreUser(plugin, UUID.fromString(user.getUUID()), "Test"); + second.setData(data); + + user.checkOfflineRewards(); + second.checkOfflineRewards(); + + verify(rewardHandler, org.mockito.Mockito.times(1)).givePersistedQueueRewardAsync( + any(AdvancedCoreUser.class), any(PersistedQueueReference.class), any(RewardOptions.class)); + } + + @Test + void offlineCheckpointMigratesOnlyItsOwnDuplicateInFlightClaim() throws Exception { + String entry = "VoteReward%placeholders%Server%pair%server-a"; + ArrayList persisted = new ArrayList<>(); + persisted.add(entry); + persisted.add(entry); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any(), eq(false)); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(pending); + + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkOfflineRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), options.capture()); + options.getValue().getAsyncReplayCheckpointConsumer().accept(replayCheckpoint(Map.of("VoteReward", 1), + new HashMap<>(Map.of("Server", "server-a")))); + + assertEquals(2, persisted.size()); + assertTrue(persisted.stream().anyMatch(value -> value.contains("%asyncprogress%v2-"))); + assertTrue(persisted.stream().allMatch(value -> value.contains("%asyncoccurrence%"))); + user.checkOfflineRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + verify(data, org.mockito.Mockito.atLeastOnce()).setStringList(eq("offlineRewardsPath"), any(), eq(false)); + + pending.complete(null); + verify(rewardHandler, org.mockito.Mockito.times(2)).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + } + + @Test + void timedAsyncReplayPreservesItsStoredCheckpointAfterExecutionMarker() { + long due = System.currentTimeMillis() - 1_000; + ArrayList timed = new ArrayList<>(); + timed.add("VoteReward%extime%1%asyncprogress%2%placeholders%Server%pair%server-a%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)).thenReturn(timed); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + + ArgumentCaptor reference = ArgumentCaptor.forClass(PersistedQueueReference.class); + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkDelayedTimedRewards(); + + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), reference.capture(), options.capture()); + assertEquals("VoteReward", reference.getValue().getReference()); + assertEquals(2, options.getValue().getCompletedAsyncInjections()); + } + + @Test + void timedAsyncCheckpointIsDurableAndMigratesTheExactEntryWhileInFlight() throws Exception { + long due = System.currentTimeMillis() - 1_000; + String original = "VoteReward%extime%12345%placeholders%Server%pair%server-a"; + ArrayList persisted = new ArrayList<>(); + persisted.add(original + "%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any(), eq(false)); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(pending); + + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkDelayedTimedRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), options.capture()); + options.getValue().getAsyncReplayCheckpointConsumer().accept(replayCheckpoint(Map.of("VoteReward", 1), + new HashMap<>(Map.of("Server", "server-a")))); + + assertEquals(1, persisted.size()); + assertTrue(persisted.get(0).startsWith("VoteReward%extime%12345%asyncoccurrence%")); + assertTrue(persisted.get(0).contains("%asyncprogress%v2-")); + assertTrue(persisted.get(0).endsWith("%ExecutionTime/%" + due)); + verify(data, org.mockito.Mockito.atLeastOnce()).setStringList(eq("TimedRewards"), any(), eq(false)); + // The key has migrated, but its in-flight claim must migrate with it too. + user.checkDelayedTimedRewards(); + verify(rewardHandler, org.mockito.Mockito.times(1)).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + } + + @Test + void pendingTimedReplayClaimIsSharedAcrossWrappersForTheSameUuid() { + long due = System.currentTimeMillis() - 1_000; + ArrayList timed = new ArrayList<>(List.of("VoteReward%ExecutionTime/%" + due)); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)).thenReturn(timed); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(any(AdvancedCoreUser.class), + any(PersistedQueueReference.class), any(RewardOptions.class))).thenReturn(pending); + AdvancedCoreUser second = new AdvancedCoreUser(plugin, UUID.fromString(user.getUUID()), "Test"); + second.setData(data); + + user.checkDelayedTimedRewards(); + second.checkDelayedTimedRewards(); + + verify(rewardHandler, org.mockito.Mockito.times(1)).givePersistedQueueRewardAsync( + any(AdvancedCoreUser.class), any(PersistedQueueReference.class), any(RewardOptions.class)); + } + + @Test + void timedRewardMutationsAreSharedAcrossUserWrappers() throws Exception { + java.util.concurrent.atomic.AtomicReference> stored = + new java.util.concurrent.atomic.AtomicReference<>(new ArrayList<>()); + java.util.concurrent.atomic.AtomicInteger reads = new java.util.concurrent.atomic.AtomicInteger(); + CountDownLatch firstRead = new CountDownLatch(1); + CountDownLatch releaseFirstRead = new CountDownLatch(1); + CountDownLatch secondRead = new CountDownLatch(1); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> { + if (reads.getAndIncrement() == 0) { + firstRead.countDown(); + await(releaseFirstRead); + } else { + secondRead.countDown(); + } + return new ArrayList<>(stored.get()); + }); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("TimedRewards"), any()); + AdvancedCoreUser second = new AdvancedCoreUser(plugin, UUID.fromString(user.getUUID()), "Test"); + second.setData(data); + Reward firstReward = mock(Reward.class); + Reward secondReward = mock(Reward.class); + when(firstReward.getRewardName()).thenReturn("First"); + when(secondReward.getRewardName()).thenReturn("Second"); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + java.util.concurrent.Future first = workers.submit( + () -> user.addTimedReward(firstReward, new HashMap<>(), 1)); + assertTrue(firstRead.await(5, TimeUnit.SECONDS)); + java.util.concurrent.Future secondTask = workers.submit( + () -> second.addTimedReward(secondReward, new HashMap<>(), 2)); + assertFalse(secondRead.await(100, TimeUnit.MILLISECONDS)); + releaseFirstRead.countDown(); + first.get(10, TimeUnit.SECONDS); + secondTask.get(10, TimeUnit.SECONDS); + } finally { + releaseFirstRead.countDown(); + workers.shutdownNow(); + } + + assertEquals(2, stored.get().size()); + } + + @Test + void unavailablePlayerFailsAsyncCommandDispatch() { + AdvancedCorePlugin.setInstance(plugin); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(UUID.fromString(user.getUUID()))).thenReturn(null); + + assertThrows(java.util.concurrent.CompletionException.class, + () -> user.preformCommandAsync(new ArrayList<>(List.of("say hello")), new HashMap<>()) + .toCompletableFuture().join()); + } finally { + AdvancedCorePlugin.setInstance(null); + } + } + + @Test + void timedAsyncCheckpointSurvivesRestartBeforeTheInFlightCompletion() throws Exception { + long due = System.currentTimeMillis() - 1_000; + ArrayList persisted = new ArrayList<>(); + persisted.add("VoteReward%extime%12345%placeholders%Server%pair%server-a%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any(), eq(false)); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(pending); + + ArgumentCaptor firstOptions = ArgumentCaptor.forClass(RewardOptions.class); + user.checkDelayedTimedRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), firstOptions.capture()); + firstOptions.getValue().getAsyncReplayCheckpointConsumer().accept(replayCheckpoint(Map.of("root/Grant:0", 1), + new HashMap<>(Map.of("Server", "server-a")))); + + AdvancedCoreUser restarted = new AdvancedCoreUser(plugin, UUID.randomUUID(), "Test"); + restarted.setData(data); + when(rewardHandler.givePersistedQueueRewardAsync(eq(restarted), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + ArgumentCaptor resumed = ArgumentCaptor.forClass(RewardOptions.class); + restarted.checkDelayedTimedRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(restarted), any(PersistedQueueReference.class), resumed.capture()); + assertEquals(1, resumed.getValue().getAsyncReplayProgress().get("root/Grant:0")); + assertEquals("server-a", resumed.getValue().getPlaceholders().get("Server")); + } + + @Test + void timedAsyncCheckpointPersistsAndRestoresItsRegistryFingerprint() throws Exception { + long due = System.currentTimeMillis() - 1_000; + ArrayList persisted = new ArrayList<>(); + persisted.add("VoteReward%placeholders%Server%pair%server-a%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any(), eq(false)); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(pending); + + ArgumentCaptor firstOptions = ArgumentCaptor.forClass(RewardOptions.class); + user.checkDelayedTimedRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), firstOptions.capture()); + firstOptions.getValue().getAsyncReplayCheckpointConsumer().accept(replayCheckpoint(Map.of("VoteReward", 1), + Map.of("VoteReward", "fingerprint", "VoteReward/0", "parent-fingerprint"), + new HashMap<>(Map.of("Server", "server-a")))); + assertTrue(persisted.get(0).contains("%asyncprogress%v3-")); + + AdvancedCoreUser restarted = new AdvancedCoreUser(plugin, UUID.randomUUID(), "Test"); + restarted.setData(data); + when(rewardHandler.givePersistedQueueRewardAsync(eq(restarted), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + ArgumentCaptor resumed = ArgumentCaptor.forClass(RewardOptions.class); + restarted.checkDelayedTimedRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(restarted), any(PersistedQueueReference.class), resumed.capture()); + assertEquals("fingerprint", resumed.getValue().getAsyncReplayRegistryFingerprints().get("VoteReward")); + assertEquals("parent-fingerprint", resumed.getValue().getAsyncReplayRegistryFingerprints().get("VoteReward/0")); + } + + @Test + void timedRetryParsesProgressAfterTheExecutionMarkerWasConsumed() { + long due = System.currentTimeMillis() - 1_000; + ArrayList timed = new ArrayList<>(); + timed.add("VoteReward%asyncprogress%2%asyncretry%1%placeholders%Server%pair%server-a%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)).thenReturn(timed); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + + ArgumentCaptor reference = ArgumentCaptor.forClass(PersistedQueueReference.class); + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkDelayedTimedRewards(); + + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), reference.capture(), options.capture()); + assertEquals("VoteReward", reference.getValue().getReference()); + assertEquals(2, options.getValue().getCompletedAsyncInjections()); + } + + private Reward.ReplayCheckpoint replayCheckpoint(Map progress, + HashMap placeholders) throws Exception { + java.lang.reflect.Constructor constructor = Reward.ReplayCheckpoint.class + .getDeclaredConstructor(Map.class, HashMap.class); + constructor.setAccessible(true); + return constructor.newInstance(progress, placeholders); + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + } + + private Reward.ReplayCheckpoint replayCheckpoint(Map progress, Map fingerprints, + HashMap placeholders) throws Exception { + java.lang.reflect.Constructor constructor = Reward.ReplayCheckpoint.class + .getDeclaredConstructor(Map.class, Map.class, HashMap.class); + constructor.setAccessible(true); + return constructor.newInstance(progress, fingerprints, placeholders); + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java index eaf79a2554..838513c60e 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java @@ -195,4 +195,35 @@ public void updateCacheDefensivelyCopiesInput() { assertTrue(cache.isCached("PlayerName")); } + + @Test + public void flushChangesAndRunPersistsQueuedWritesBeforeTheSynchronousAction() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager userManager = mock(UserManager.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserData userData = mock(UserData.class); + UserDataManager manager = mock(UserDataManager.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + ScheduledFuture future = mock(ScheduledFuture.class); + UUID uuid = UUID.randomUUID(); + + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(timer); + when(plugin.getUserManager()).thenReturn(userManager); + when(userManager.getUser(uuid, false)).thenReturn(user); + when(user.getUserData()).thenReturn(userData); + doReturn(future).when(timer).schedule(any(Runnable.class), eq(3L), eq(TimeUnit.SECONDS)); + + java.util.concurrent.atomic.AtomicBoolean flushed = new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer(ignored -> { + flushed.set(true); + return null; + }).when(userData).setValues(any(HashMap.class)); + UserDataCache cache = new UserDataCache(manager, uuid); + cache.addChange(new UserDataChangeString("TimedRewards", "old-value"), true); + + cache.flushChangesAndRun(() -> assertTrue(flushed.get(), + "the durable replay checkpoint must follow the queued cache flush")); + assertFalse(cache.hasChangesToProcess()); + } }