diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCoreConfigOptions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCoreConfigOptions.java index 907bf1dfe9..1d111df9ce 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCoreConfigOptions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCoreConfigOptions.java @@ -627,6 +627,12 @@ public class AdvancedCoreConfigOptions { * @return the list of worlds where default rewards are disabled * @param defaultRewardBlackListedWorlds the list of worlds to set */ + /** Default gamemode allowlist; an explicit reward GameModes list overrides it. */ + @Getter + @Setter + @ConfigDataListString(path = "DefaultRewardGameModes") + private ArrayList defaultRewardGameModes = new ArrayList<>(); + @Getter @Setter @ConfigDataListString(path = "DefaultRewardBlackListedWorlds") @@ -679,6 +685,7 @@ public Sound getClickSoundSound() { */ public void load(AdvancedCorePlugin plugin) { if (getYmlConfig() != null) { + defaultRewardGameModes = new ArrayList<>(); new AnnotationHandler().load(getYmlConfig().getData(), this); debug = DebugLevel.getDebug(debugLevelStr); storageType = UserStorage.value(userStorageString.toUpperCase()); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index 4fda22913a..dc3d627f67 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -814,6 +814,7 @@ public void loadAdvancedCoreEvents() { Bukkit.getPluginManager().registerEvents(FireworkHandler.getInstance(), this); Bukkit.getPluginManager().registerEvents(new BInventoryListener(this), this); + Bukkit.getPluginManager().registerEvents(new com.bencodez.advancedcore.listeners.RewardEligibilityListener(this), this); } private void loadConfig(boolean userStorage) { @@ -1442,6 +1443,7 @@ public void onEnable() { } getRewardHandler().checkSubRewards(); getRewardHandler().checkDirectlyDefinedRewardFiles(); + getRewardHandler().getRewardEligibility().bootstrapOnlinePlayers(); } /** diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/VaultHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/VaultHandler.java index 0de084b974..05c006dc8d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/VaultHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/VaultHandler.java @@ -36,6 +36,30 @@ public void run() { plugin.getRewardHandler() .addInjectedRequirements(new RequirementInjectString("VaultGroup", "") { + @Override + public java.util.concurrent.CompletionStage onRequirementRequestAsync( + Reward reward, AdvancedCoreUser user, + org.bukkit.configuration.ConfigurationSection data, RewardOptions options) { + if (!appliesTo(data)) return java.util.concurrent.CompletableFuture.completedFuture(true); + String type = data.getString(getPath(), getDefaultValue()); + if (type.isEmpty()) return java.util.concurrent.CompletableFuture.completedFuture(true); + java.util.function.Supplier> offline = + () -> reward.evaluateAsyncRequirement(() -> { + String group = getPerms().getPrimaryGroup(null, user.getOfflinePlayer()); + return group != null && group.equalsIgnoreCase(type); + }); + if (options.isGiveOffline()) return offline.get(); + return reward.evaluatePlayerRequirement(user, player -> { + if (player != null && user.isOnline()) { + if (!player.isOnline()) return java.util.concurrent.CompletableFuture.completedFuture(false); + String group = getPerms().getPrimaryGroup(player); + return java.util.concurrent.CompletableFuture.completedFuture( + group != null && group.equalsIgnoreCase(type)); + } + return offline.get(); + }); + } + @Override public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, String type, RewardOptions rewardOptions) { 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 a46ba56337..85a39d263d 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 @@ -12,6 +12,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -26,6 +27,7 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.rewards.RewardEligibility; import com.bencodez.advancedcore.data.ServerData; import com.bencodez.simpleapi.messages.MessageAPI; @@ -121,12 +123,22 @@ public void giveItem(Player player, ItemStack... item) { * they never checkpoint a queued delivery as completed. */ public CompletionStage giveItemAsync(Player player, ItemStack... item) { + return giveItemWhenEligibleAsync(player, null, item); + } + + /** Replay-aware delivery with an optional final owner-thread eligibility check. */ + public CompletionStage giveItemWhenEligibleAsync(Player player, BooleanSupplier eligibility, ItemStack... item) { CompletableFuture completion = new CompletableFuture<>(); - scheduleItemDelivery(player, item, completion); + scheduleItemDelivery(player, item, completion, eligibility); return completion; } private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFuture completion) { + scheduleItemDelivery(player, item, completion, null); + } + + private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFuture completion, + BooleanSupplier eligibility) { if (player == null || item == null || item.length == 0) { if (completion != null) completion.complete(null); return; @@ -163,7 +175,14 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu deliveryLock.readLock().lock(); replayDeliveryLocked = true; } - if (completion != null) validateReplayDeliveryTarget(player, deliveryPlayerId); + if (completion != null) validateReplayDeliveryTarget(player, deliveryPlayerId, eligibility != null); + // Check only before the initial mutation. Retained overflow is already + // committed and must remain deliverable if eligibility later changes. + if (eligibility != null && !eligibility.getAsBoolean()) { + throw AdvancedCoreUser.replayActionNotStarted( + "Player reward context became unavailable before item mutation", + com.bencodez.advancedcore.api.rewards.Reward.playerEligibilityDeferred()); + } if (completion != null) replayOverflowCompletions.put(reservationId, completion); boolean pendingOverflow = giveItemOwnedPlayer(player, itemsToGive, reservationId); if (completion != null) { @@ -188,7 +207,18 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu } }; try { - plugin.getBukkitScheduler().runTask(plugin, delivery, player); + if (completion != null && eligibility != null) { + RewardEligibility.runPlayerTask(plugin, player, delivery, () -> { + if (deliveryClaimed.compareAndSet(false, true)) { + completion.completeExceptionally(RewardEligibility.retiredPlayerActionFailure(plugin)); + } + }, failure -> { + if (deliveryClaimed.compareAndSet(false, true)) { + completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Scheduler rejected item delivery before dispatch", failure)); + } + }); + } else plugin.getBukkitScheduler().runTask(plugin, delivery, player); } catch (Throwable failure) { deliveryClaimed.set(true); if (completion != null) completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( @@ -215,13 +245,14 @@ protected long getItemDeliveryTimeoutMillis() { * 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) { + private void validateReplayDeliveryTarget(Player player, UUID playerId, boolean eligibilityDeferral) { 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"); + throw AdvancedCoreUser.replayActionNotStarted("Player became unavailable before item delivery", + eligibilityDeferral ? com.bencodez.advancedcore.api.rewards.Reward.playerEligibilityDeferred() : null); } } 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 d83500f632..511c9abe8c 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 @@ -274,6 +274,13 @@ public void executeConsoleCommands(String playerName, String command, HashMap executeConsoleCommandsAsync(String playerName, String command, HashMap placeholders) { + return executeConsoleCommandsAsync(playerName, command, placeholders, + AdvancedCoreUser.captureCurrentPlayerEligibilityGuard()); + } + + /** Carries the originating reward's optional player policy into the actual dispatch. */ + public CompletionStage executeConsoleCommandsAsync(String playerName, String command, + HashMap placeholders, AdvancedCoreUser.PlayerEligibilityGuard eligibilityGuard) { try { ArrayList templates = command == null || command.isEmpty() ? new ArrayList<>() : new ArrayList<>(java.util.List.of(command)); @@ -289,7 +296,7 @@ public CompletionStage executeConsoleCommandsAsync(String playerName, Stri return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, (cmd, ignoredIndex) -> { plugin.debug("Executing console command: " + cmd); - return runConsoleCommandAsync(stripLeadingSlash(cmd), 0, false, false); + return runConsoleCommandAsync(stripLeadingSlash(cmd), 0, false, false, eligibilityGuard); }); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); @@ -301,9 +308,17 @@ public CompletionStage executeConsoleCommandsAsync(String playerName, Stri * 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) { + return executeConsoleCommandsAsync(playerName, cmds, placeholders, stagger, + AdvancedCoreUser.captureCurrentPlayerEligibilityGuard()); + } + + /** Retains the legacy stagger schedule while checking a captured policy for every command. */ + @SuppressWarnings("deprecation") + public CompletionStage executeConsoleCommandsAsync(final String playerName, final ArrayList cmds, + final HashMap placeholders, final boolean stagger, + AdvancedCoreUser.PlayerEligibilityGuard eligibilityGuard) { try { placeholders.put("player", playerName); OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); @@ -317,7 +332,7 @@ public CompletionStage executeConsoleCommandsAsync(final String playerName (command, index) -> { plugin.debug("Executing console command: " + command); return runConsoleCommandAsync(stripLeadingSlash(command), index > 0 ? 1 : 0, - stagger && index > 0, stagger || index == 0); + stagger && index > 0, stagger || index == 0, eligibilityGuard); }); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); @@ -535,18 +550,45 @@ private void runConsoleCommand(String command, int delay, boolean hasDelay) { * after a failed replay has already been retried. */ private CompletionStage runConsoleCommandAsync(String command, int delay, boolean hasDelay, - boolean scheduleAsTask) { + boolean scheduleAsTask, AdvancedCoreUser.PlayerEligibilityGuard eligibilityGuard) { CompletableFuture completion = new CompletableFuture<>(); AtomicBoolean claimed = new AtomicBoolean(); - Runnable dispatch = () -> { + Runnable finalDispatch = () -> { if (!claimed.compareAndSet(false, true)) return; try { + if (eligibilityGuard != null && !plugin.isEnabled()) { + throw new IllegalStateException("Plugin disabled before console command dispatch"); + } Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), command); completion.complete(null); } catch (Throwable failure) { completion.completeExceptionally(failure); } }; + AtomicBoolean started = new AtomicBoolean(); + Runnable dispatch = eligibilityGuard == null ? finalDispatch : () -> { + if (!started.compareAndSet(false, true) || claimed.get()) return; + try { + eligibilityGuard.checkAsync().whenComplete((ignored, failure) -> { + if (failure != null) { + claimed.set(true); + completion.completeExceptionally(failure); + return; + } + try { + // Only the captured eligibility result crosses back to the global owner. + // Folia cannot make this handoff atomic with a player mode transition. + if (!claimed.get()) plugin.getBukkitScheduler().executeOrScheduleSync(plugin, finalDispatch); + } catch (Throwable schedulingFailure) { + claimed.set(true); + completion.completeExceptionally(schedulingFailure); + } + }); + } catch (Throwable failure) { + claimed.set(true); + completion.completeExceptionally(failure); + } + }; try { if (hasDelay && delay > 0) plugin.getBukkitScheduler().runTaskLater(plugin, dispatch, delay); else if (scheduleAsTask) plugin.getBukkitScheduler().runTask(plugin, dispatch); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java index 43bc557d16..0132ec6554 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java @@ -280,6 +280,19 @@ public boolean hasServerPermission(UUID playerUUID, String playername, String pe return false; } + /** Offline provider phase; callers must capture the world name on the server scheduler. */ + public boolean hasOfflineServerPermission(UUID playerUUID, String world, String perm) { + if (playerUUID == null) return false; + if (plugin.getLuckPermsHandle() != null && plugin.getLuckPermsHandle().luckpermsApiLoaded()) { + if (plugin.getLuckPermsHandle().hasOfflinePermission(playerUUID, perm)) return true; + } + if (plugin.getOptions().isUseVaultPermissions() && plugin.getVaultHandler() != null + && plugin.getVaultHandler().getPerms() != null && plugin.getVaultHandler().getPerms().isEnabled()) { + return plugin.getVaultHandler().getPerms().playerHas(world, Bukkit.getOfflinePlayer(playerUUID), perm); + } + return false; + } + /** @deprecated Use {@link #isValidUserAsync(String, boolean, Consumer, Consumer)} for persisted identity. */ @Deprecated public boolean isValidUser(String name) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/LuckPermsHandle.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/LuckPermsHandle.java index f9c7500106..dcb7a1d10b 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/LuckPermsHandle.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/LuckPermsHandle.java @@ -80,6 +80,30 @@ public boolean hasPermission(UUID uuid, String permission) { return negate ? !has : has; } + /** + * Worker-only static-context lookup. Unlike hasPermission, this never discovers + * an online player; a reconnect cannot move native permission reads onto a worker. + */ + public boolean hasOfflinePermission(UUID uuid, String permission) { + if (api == null) return false; + boolean negate = permission.startsWith("!"); + if (negate) permission = permission.substring(1); + User user = api.getUserManager().getUser(uuid); + if (user == null) { + try { + user = api.getUserManager().loadUser(uuid).get(10, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } catch (Exception failure) { + return false; + } + } + if (user == null) return false; + boolean allowed = checkUserPermission(user, permission, null); + return negate ? !allowed : allowed; + } + /** * Async permission check for offline users. Safe to call from the main thread. */ 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 d3fa202130..2ef7d050c3 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 @@ -57,6 +57,7 @@ public class Reward { 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_"; + private static final String REPLAY_CHANCE_PREFIX = "__advancedcore_replay_chance_"; @Getter @Setter @@ -138,6 +139,7 @@ public Reward(String name, ConfigurationSection section) { load(name, section); } + /** Caller must hold the player owner when evaluating live requirements. */ public boolean canGiveReward(AdvancedCoreUser user, RewardOptions options) { for (RequirementInject inject : plugin.getRewardHandler().getInjectedRequirements()) { try { @@ -154,6 +156,33 @@ public boolean canGiveReward(AdvancedCoreUser user, RewardOptions options) { return true; } + /** Evaluates ordered requirements without blocking the caller or moving scripts onto the player owner. */ + public CompletionStage canGiveRewardAsync(AdvancedCoreUser user, RewardOptions options) { + RewardOptions stableOptions = options == null ? new RewardOptions() : options.copyForDispatch(); + CompletionStage result = CompletableFuture.completedFuture(true); + for (RequirementInject inject : plugin.getRewardHandler().getInjectedRequirements()) { + result = result.thenCompose(allowed -> allowed + ? requestRequirementAsync(inject, user, stableOptions).exceptionally(failure -> { + plugin.debug("Failed to check requirement " + inject.getPath()); + plugin.debug(failure); + return false; + }) : CompletableFuture.completedFuture(false)); + } + return result; + } + + private CompletionStage requestRequirementAsync(RequirementInject inject, AdvancedCoreUser user, + RewardOptions options) { + return continueOffServerThread(() -> { + if (!plugin.isEnabled()) return CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before reward requirement evaluation")); + inject.prepareAsyncRequest(this, getConfig().getConfigData(), options); + Supplier> check = () -> + inject.onRequirementRequestAsync(this, user, getConfig().getConfigData(), options); + return inject.requiresPlayerThread() ? requestOnServerThread(user, check) : check.get(); + }); + } + public boolean checkDelayed(AdvancedCoreUser user, HashMap placeholders) { if (!isDelayEnabled()) { return false; @@ -216,6 +245,13 @@ public String getRewardName() { } public void giveInjectedRewards(AdvancedCoreUser user, HashMap placeholders) { + if (RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) { + giveRewardUserAsync(user, placeholders, new RewardOptions().setCheckTimed(false)).exceptionally(failure -> { + logRewardUserFailure(failure); + return null; + }); + return; + } ArrayList postReward = new ArrayList<>(); @@ -284,6 +320,9 @@ public void giveInjectedRewards(AdvancedCoreUser user, HashMap p */ public CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, HashMap placeholders) { + if (RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) { + return giveRewardUserAsync(user, placeholders, new RewardOptions().setCheckTimed(false)); + } return giveInjectedRewardsAsync(user, placeholders, 0, new ReplayState(null), getRewardName(), UUID.randomUUID().toString()); } @@ -304,6 +343,12 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, HashMap placeholders, int completedStages, ReplayState replayState, String replayKey, String occurrenceId) { + return giveInjectedRewardsAsync(user, placeholders, completedStages, replayState, replayKey, occurrenceId, null); + } + + private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, + HashMap placeholders, int completedStages, ReplayState replayState, String replayKey, + String occurrenceId, RewardOptions eligibilityOptions) { List orderedRewards = orderedInjectedRewards(); int resumeAfter = replayState.getCompleted(replayKey, completedStages); if (resumeAfter < 0 || resumeAfter > orderedRewards.size()) { @@ -329,7 +374,7 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, // 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); + return invokeInjectionAsync(inject, user, placeholders, replayState, injectionKey, occurrenceId, eligibilityOptions); }) .thenCompose(result -> { if (inject.isAddAsPlaceholder() && result != null) addPlaceholder(inject, result, placeholders); @@ -600,7 +645,8 @@ public static CompletionStage replayCommandSequence(AdvancedCorePlugin plu currentReplayState(), currentReplayKey(), dispatch); } - static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + /** Explicit replay context for command continuations after an asynchronous handoff. */ + public static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, HashMap placeholders, String lane, List commandTemplates, List expandedCommands, ReplayState replayState, String activeKey, @@ -977,7 +1023,7 @@ public static void mergeReplayMetadata(HashMap target, Map checkpointConsumer; + private String queueReplayKey; private ReplayState(Map initial) { this(initial, null, false); } private ReplayState(Map initial, Map initialFingerprints, boolean legacyCheckpoint) { @@ -1144,6 +1191,13 @@ private synchronized void applyLivePlayerState(RewardOptions options) { private synchronized boolean hasCheckpointConsumer() { return checkpointConsumer != null; } + private synchronized String replayPlaceholderKey(String executionKey, boolean ownsCheckpoint) { + // The first queued dispatch owns this shared state's durable entry. Nested + // children keep their own ordinal paths, even if their options inherit the + // consumer, but their checkpoints must still identify the queued parent. + if (ownsCheckpoint && queueReplayKey == null) queueReplayKey = executionKey; + return checkpointConsumer != null && queueReplayKey != null ? queueReplayKey : executionKey; + } private synchronized boolean acceptsPersistedMetadata() { return restoredCheckpoint || checkpointConsumer != null; } @@ -1204,12 +1258,28 @@ private ReplayCheckpoint(Map replayProgress, Map invokeInjectionAsync(RewardInject inject, AdvancedCoreUser user, - HashMap placeholders, ReplayState replayState, String injectionKey, String occurrenceId) { + HashMap placeholders, ReplayState replayState, String injectionKey, String occurrenceId, + RewardOptions eligibilityOptions) { 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)); + Supplier> request = () -> requestOnServerThread(plugin, user, ownerPlayer -> { + if (eligibilityOptions != null && !bypassesPlayerEligibility(eligibilityOptions) + && !checkPlayerEligibility(user, eligibilityOptions)) { + return CompletableFuture.failedFuture(new PlayerEligibilityDeferredException()); + } + Supplier> injection = () -> { + if (!plugin.isEnabled()) return CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before reward injection completed")); + return requestInjectionAsync(inject, user, placeholders, replayState, injectionKey, occurrenceId, + eligibilityOptions, ownerPlayer); + }; + // Opted-in live eligibility owns only the native observation. Callback + // bodies may do blocking provider/script work; collected game actions + // retain their own final owner-thread eligibility checks. + return RewardEligibility.isConfigured(plugin, getConfig().getConfigData()) + ? continueOffServerThread(injection) : injection.get(); + }, getServerThreadDispatchTimeoutMillis(), null); CompletionStage result = inject.isSynchronize() && inject.supportsAsyncSynchronization() ? inject.runSynchronizedAsync(request) : request.get(); @@ -1234,10 +1304,28 @@ public static CompletionStage continueOnServerThread(AdvancedCorePlugin p return requestOnServerThread(plugin, user, request, TimeUnit.SECONDS.toMillis(30)); } + /** Runs a continuation on the exact native session supplied by a login event. */ + static CompletionStage continueOnPlayerThread(AdvancedCorePlugin plugin, Player player, + Supplier> request) { + return requestOnServerThread(plugin, null, ignored -> request.get(), TimeUnit.SECONDS.toMillis(30), + java.util.Objects.requireNonNull(player, "player")); + } + private static CompletionStage requestOnServerThread(AdvancedCorePlugin plugin, AdvancedCoreUser user, Supplier> request, long timeoutMillis) { + return requestOnServerThread(plugin, user, ignored -> request.get(), timeoutMillis, null); + } + + /** Resolves off the entity owner, then supplies that exact session on its owner. */ + static CompletionStage continueOnResolvedPlayerThread(AdvancedCorePlugin plugin, AdvancedCoreUser user, + Function> request) { + return requestOnServerThread(plugin, user, request, TimeUnit.SECONDS.toMillis(30), null); + } + + private static CompletionStage requestOnServerThread(AdvancedCorePlugin plugin, AdvancedCoreUser user, + Function> request, long timeoutMillis, Player nativeSession) { CompletableFuture> handoff = new CompletableFuture<>(); - Runnable invocation = () -> { + Consumer invocation = ownerPlayer -> { if (!plugin.isEnabled()) { handoff.complete(CompletableFuture.failedFuture( new IllegalStateException("Plugin disabled before reward injection completed"))); @@ -1248,7 +1336,7 @@ private static CompletionStage requestOnServerThread(AdvancedCorePlugin p // queued task must not produce late reward side effects. if (!handoff.complete(result)) return; try { - CompletionStage stage = request.get(); + CompletionStage stage = request.apply(ownerPlayer); if (stage == null) { result.completeExceptionally( new IllegalStateException("Reward injection returned a null asynchronous result")); @@ -1269,9 +1357,10 @@ private static CompletionStage requestOnServerThread(AdvancedCorePlugin p return; } try { - Player player = user.getPlayer(); - if (player != null) plugin.getBukkitScheduler().executeOrScheduleSync(plugin, invocation, player); - else invocation.run(); + Player player = nativeSession != null ? nativeSession : user.getPlayer(); + if (player != null) plugin.getBukkitScheduler().executeOrScheduleSync(plugin, + () -> invocation.accept(player), player); + else invocation.accept(null); } catch (Throwable failure) { handoff.completeExceptionally(failure); } @@ -1295,7 +1384,8 @@ protected long getServerThreadDispatchTimeoutMillis() { } private CompletionStage requestInjectionAsync(RewardInject inject, AdvancedCoreUser user, - HashMap placeholders, ReplayState replayState, String injectionKey, String occurrenceId) { + HashMap placeholders, ReplayState replayState, String injectionKey, String occurrenceId, + RewardOptions eligibilityOptions, Player ownerPlayer) { ReplayState previous = ACTIVE_REPLAY_STATE.get(); String previousKey = ACTIVE_REPLAY_KEY.get(); String previousOccurrenceId = ACTIVE_REPLAY_OCCURRENCE_ID.get(); @@ -1303,14 +1393,20 @@ private CompletionStage requestInjectionAsync(RewardInject inject, Advan 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); + Supplier playerEligibility = null; + if (eligibilityOptions != null && !bypassesPlayerEligibility(eligibilityOptions) + && RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) { + playerEligibility = () -> checkPlayerEligibility(user, eligibilityOptions); + } + AdvancedCoreUser.AsyncActionCollection actionCollection = playerEligibility == null + ? user.beginAsyncActionCollection(replayState, placeholders, injectionKey) + : user.beginAsyncActionCollection(replayState, placeholders, injectionKey, playerEligibility, ownerPlayer); 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) { + && (playerEligibility == null ? user.getPlayer() : ownerPlayer) == null) { result = CompletableFuture.failedFuture( new IllegalStateException("Player became unavailable before " + inject.getPath() + " delivery")); } else if (inject.supportsAsyncRequest()) { @@ -1381,6 +1477,16 @@ private boolean isRewardInjectionApplicable(RewardInject inject) { } public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { + // Preserve the asynchronous event contract while new live predicates use the + // player owner. Unconfigured legacy definitions retain their established path. + if (RewardEligibility.isConfigured(plugin, getConfig().getConfigData()) + && (rewardOptions == null || !rewardOptions.isIgnoreRequirements())) { + giveRewardAsync(user, rewardOptions).exceptionally(failure -> { + logRewardUserFailure(failure); + return null; + }); + return; + } if (!plugin.getOptions().isProcessRewards()) { plugin.debug("Processing rewards is disabled"); return; @@ -1580,53 +1686,114 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R return CompletableFuture.completedFuture(null); } RewardOptions stableOptions = rewardOptions; - return requestOnServerThread(user, () -> evaluateLiveRewardDecision(user, stableOptions)) - .thenCompose(decision -> finishRewardAsync(user, stableOptions, decision)); + return requestOnServerThread(user, () -> CompletableFuture.completedFuture( + captureLiveRewardDecision(user, stableOptions))) + .thenCompose(snapshot -> continueOffServerThread( + () -> evaluateLiveRewardDecision(user, stableOptions, snapshot))) + .thenCompose(decision -> continueOffServerThread( + () -> finishRewardAsync(user, stableOptions, decision))) + .exceptionallyCompose(failure -> CompletableFuture.failedFuture(isDurableReplay(stableOptions) + && hasApprovedReplayChance(stableOptions) + ? replayFailureWithSnapshot(stableOptions, failure) : failure)); } - private CompletionStage evaluateLiveRewardDecision(AdvancedCoreUser user, - RewardOptions rewardOptions) { - boolean liveOnline = rewardOptions.isLivePlayerStateSet() ? rewardOptions.isOnline() : user.isOnline(); + /** Deterministic player-context check, invoked only on the owning scheduler. */ + public boolean checkPlayerEligibility(AdvancedCoreUser user, RewardOptions options) { + if (bypassesPlayerEligibility(options) || !RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) return true; + RewardEligibility eligibility = plugin.getRewardHandler().getRewardEligibility(); + boolean allowed = eligibility.isEligible(getConfig().getConfigData(), user); + if (!allowed && (options.isGiveOffline() || options.isTimedQueueReplay())) eligibility.deferred(user); + return allowed; + } + + private boolean bypassesPlayerEligibility(RewardOptions options) { + return options.isIgnoreRequirements() || options.isForceOffline() || isForceOffline(); + } + + private static final class PlayerEligibilityDeferredException extends IllegalStateException { + private static final long serialVersionUID = 1L; + private PlayerEligibilityDeferredException() { super("Player reward context remains unavailable"); } + } + + /** Internal cross-package signal: a player action must remain queued for eligibility. */ + public static RuntimeException playerEligibilityDeferred() { + return new PlayerEligibilityDeferredException(); + } + + public static boolean isPlayerEligibilityDeferred(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof PlayerEligibilityDeferredException) return true; + } + return false; + } + + private String chanceReplayKey(RewardOptions options) { + String key = options.getAsyncReplayKey() == null ? getRewardName() : options.getAsyncReplayKey(); + return REPLAY_CHANCE_PREFIX + Base64.getUrlEncoder().withoutPadding() + .encodeToString(key.getBytes(StandardCharsets.UTF_8)); + } + + private boolean hasApprovedReplayChance(RewardOptions options) { + String key = chanceReplayKey(options); + return "1".equals(options.getPlaceholders().get(key)) || (options.getAsyncReplayState() != null + && "1".equals(options.getAsyncReplayState().replayMetadata(key))); + } + + /** Captures only live player state; no scripts, placeholders or custom providers run here. */ + private LiveRewardDecision captureLiveRewardDecision(AdvancedCoreUser user, RewardOptions options) { + boolean liveOnline = options.isLivePlayerStateSet() ? options.isOnline() : user.isOnline(); boolean vanished = plugin.getOptions().isTreatVanishAsOffline() - && (rewardOptions.isLivePlayerStateSet() ? rewardOptions.isLivePlayerVanished() : user.isVanished()); - if (!rewardOptions.isLivePlayerStateSet()) rewardOptions.captureLivePlayerState(liveOnline, vanished); + && (options.isLivePlayerStateSet() ? options.isLivePlayerVanished() : user.isVanished()); + if (!options.isLivePlayerStateSet()) options.captureLivePlayerState(liveOnline, vanished); + boolean eligible = checkPlayerEligibility(user, options); + boolean verifyOnline = !options.isOnline() || options.getServer() != null; + return new LiveRewardDecision(eligible, !eligible, vanished, verifyOnline && !liveOnline, false, !eligible); + } + + private CompletionStage evaluateLiveRewardDecision(AdvancedCoreUser user, + RewardOptions options, LiveRewardDecision snapshot) { for (RewardPlaceholderHandle handle : plugin.getRewardHandler().getPlaceholders()) { - if (handle.isPreProcess()) rewardOptions.addPlaceholder(handle.getKey(), handle.getValue(this, user)); + if (handle.isPreProcess()) options.addPlaceholder(handle.getKey(), handle.getValue(this, user)); } - - boolean allowOffline = false; - boolean canGive = true; - if (!rewardOptions.isIgnoreRequirements()) { + CompletionStage decision = CompletableFuture.completedFuture(snapshot); + if (!options.isIgnoreRequirements()) { for (RequirementInject inject : plugin.getRewardHandler().getInjectedRequirements()) { - try { - if (!inject.onRequirementRequest(this, user, getConfig().getConfigData(), rewardOptions)) { - canGive = false; - if (!inject.isAllowReattempt()) { - return CompletableFuture.completedFuture( - new LiveRewardDecision(false, false, vanished, false, true)); + decision = decision.thenCompose(current -> { + if (current.terminalDenied() || ("Chance".equals(inject.getPath()) + && hasApprovedReplayChance(options))) return CompletableFuture.completedFuture(current); + return requestRequirementAsync(inject, user, options).handle((allowed, failure) -> { + if (failure != null) { + plugin.debug("Failed to check requirement " + inject.getPath()); + plugin.debug(failure); + if (isDurableReplay(options)) throw new java.util.concurrent.CompletionException( + new IllegalStateException("Failed to evaluate reward requirement " + inject.getPath(), failure)); + return new LiveRewardDecision(false, current.allowOffline(), current.vanished(), + current.liveOffline(), false, current.playerEligibilityDenied()); } - 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 (!allowed) return new LiveRewardDecision(false, inject.isAllowReattempt() || current.allowOffline(), + current.vanished(), current.liveOffline(), !inject.isAllowReattempt(), + current.playerEligibilityDenied() || "GameModes".equals(inject.getPath())); + if ("Chance".equals(inject.getPath()) + && RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) { + String key = chanceReplayKey(options); + options.addPlaceholder(key, "1"); + if (options.getAsyncReplayState() != null) options.getAsyncReplayState().recordReplayMetadata(key, "1"); + } + return current; + }); + }); } } - boolean verifyOnline = !rewardOptions.isOnline() || rewardOptions.getServer() != null; - boolean liveOffline = verifyOnline && !liveOnline; - return CompletableFuture.completedFuture( - new LiveRewardDecision(canGive, allowOffline, vanished, liveOffline, false)); + return decision; } private CompletionStage finishRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions, LiveRewardDecision decision) { if (decision.terminalDenied()) return CompletableFuture.completedFuture(null); + if (decision.playerEligibilityDenied() && rewardOptions.isTimedQueueReplay() + && !isForceOffline() && !rewardOptions.isForceOffline()) { + return CompletableFuture.failedFuture(playerEligibilityDeferred()); + } boolean vanished = decision.vanished(); if (plugin.getOptions().isPauseRewards() || vanished) { return deferRewardWithoutBlockingOwner(user, rewardOptions); @@ -1660,7 +1827,22 @@ private CompletionStage deferRewardWithoutBlockingOwner(AdvancedCoreUser u } private record LiveRewardDecision(boolean canGive, boolean allowOffline, boolean vanished, boolean liveOffline, - boolean terminalDenied) { } + boolean terminalDenied, boolean playerEligibilityDenied) { } + + /** Resolve the live session globally, then evaluate native requirement state on its owner. */ + public CompletionStage evaluatePlayerRequirement(AdvancedCoreUser user, + Function> request) { + return requestOnServerThread(plugin, user, request, getServerThreadDispatchTimeoutMillis(), null); + } + + /** Await potentially blocking requirement-provider work on the existing reward worker. */ + public CompletionStage evaluateAsyncRequirement(Supplier request) { + return continueOffServerThread(() -> { + if (!plugin.isEnabled()) return CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before reward requirement evaluation")); + return CompletableFuture.completedFuture(request.get()); + }); + } private CompletionStage continueOffServerThread(Supplier> request) { CompletableFuture> handoff = new CompletableFuture<>(); @@ -1699,11 +1881,28 @@ private CompletionStage deferRewardAsync(AdvancedCoreUser user, RewardOpti return CompletableFuture.failedFuture(new OfflineReplayDeferredException()); } user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); + if (!isDurableReplay(rewardOptions) && RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) { + plugin.getRewardHandler().getRewardEligibility().queued(user); + } return rewardOptions.getAsyncReplayCheckpointConsumer() == null ? CompletableFuture.completedFuture(null) : CompletableFuture.failedFuture(new OfflineReplayDeferredException()); } + private RewardReplayFailure replayFailureWithSnapshot(RewardOptions options, Throwable cause) { + RewardReplayFailure previous = findReplayFailure(cause); + ReplayState state = previous == null ? replayStateFor(options) : previous.replayState; + HashMap snapshot = new HashMap<>(previous == null + ? options.getPlaceholders() : previous.getReplayPlaceholders()); + HashMap optionMetadata = new HashMap<>(); + mergeReplayMetadata(optionMetadata, options.getPlaceholders()); + optionMetadata.forEach(snapshot::putIfAbsent); + String key = options.getAsyncReplayKey() == null ? getRewardName() : options.getAsyncReplayKey(); + String queuedKey = state.replayPlaceholderKey(key, options.getAsyncReplayCheckpointConsumer() != null); + if (queuedKey != null) snapshot.put("__AdvancedCoreAsyncReplayKey", queuedKey); + return new RewardReplayFailure(state, snapshot, cause); + } + /** * Give reward user. * @@ -1745,7 +1944,9 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< // caller's values before that boundary so a reused mutable map cannot alter a // queued reward or its durable replay checkpoint. HashMap placeholders = phs == null ? new HashMap<>() : new HashMap<>(phs); - ReplayState replayState = replayStateFor(rewardOptions); + RewardOptions deliveryOptions = rewardOptions.copyForDispatch(); + ReplayState replayState = replayStateFor(deliveryOptions); + deliveryOptions.setAsyncReplayState(replayState); List orderedRewards = orderedInjectedRewards(); String registryFingerprint = injectionRegistryFingerprint(orderedRewards); String replayKey = rewardOptions.getAsyncReplayKey(); @@ -1768,9 +1969,30 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< } final String stableReplayKey = replayKey; final String stableOccurrenceId = occurrenceId; + deliveryOptions.setAsyncReplayOccurrenceId(stableOccurrenceId); + deliveryOptions.setAsyncReplayKey(stableReplayKey); + String queuedReplayKey = replayState.replayPlaceholderKey(stableReplayKey, + deliveryOptions.getAsyncReplayCheckpointConsumer() != null); + if (queuedReplayKey != null) { + deliveryOptions.addPlaceholder("__AdvancedCoreAsyncReplayKey", queuedReplayKey); + placeholders.put("__AdvancedCoreAsyncReplayKey", queuedReplayKey); + } return requestOnServerThread(user, - () -> prepareAndGiveRewardUserAsync(user, placeholders, rewardOptions, replayState, stableReplayKey, - stableOccurrenceId, registryFingerprint, !orderedRewards.isEmpty())); + () -> prepareAndGiveRewardUserAsync(user, placeholders, deliveryOptions, replayState, stableReplayKey, + stableOccurrenceId, registryFingerprint, !orderedRewards.isEmpty())) + .exceptionallyCompose(failure -> { + if (!isPlayerEligibilityDeferred(failure)) return CompletableFuture.failedFuture(failure); + RewardReplayFailure replayFailure = findReplayFailure(failure); + if (replayFailure != null) deliveryOptions.withPlaceHolder(replayFailure.getReplayPlaceholders()); + if (!deliveryOptions.isGiveOffline() && !deliveryOptions.isTimedQueueReplay()) { + return CompletableFuture.completedFuture(null); + } + // A restored durable occurrence still needs a future eligibility wake-up. + // Retirement can defer it without reaching another live mode check. + plugin.getRewardHandler().getRewardEligibility().queued(user); + if (deliveryOptions.isTimedQueueReplay()) return CompletableFuture.failedFuture(failure); + return deferRewardWithoutBlockingOwner(user, deliveryOptions); + }); } private CompletionStage prepareAndGiveRewardUserAsync(AdvancedCoreUser user, HashMap phs, @@ -1791,7 +2013,7 @@ private CompletionStage prepareAndGiveRewardUserAsync(AdvancedCoreUser use phs == null ? new HashMap<>() : phs, unavailable)); } return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, - replayKey, occurrenceId) + replayKey, occurrenceId, rewardOptions) .thenRun(() -> plugin.debug("Gave " + user.getPlayerName() + " reward " + name)); } @@ -1802,7 +2024,8 @@ private static boolean hasPersistedReplayCheckpoint(RewardOptions options) { } private boolean requiresAwaitedDelivery(RewardOptions options) { - return options.getAsyncReplayCheckpointConsumer() != null || hasAsyncRewardInjection() + return RewardEligibility.isConfigured(plugin, getConfig().getConfigData()) + || options.getAsyncReplayCheckpointConsumer() != null || hasAsyncRewardInjection() || hasPersistedReplayCheckpoint(options); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java new file mode 100644 index 0000000000..29f9168960 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java @@ -0,0 +1,593 @@ +package com.bencodez.advancedcore.api.rewards; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +import org.bukkit.GameMode; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.metadata.FixedMetadataValue; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; + +/** Owns optional eligibility observations and bounded replay wake-ups, never reward payloads. */ +public final class RewardEligibility { + public static final int MAX_PENDING_PLAYERS = 4096; + private static final int FALLBACK_BATCH = 128; + private static final String LOGIN_RECEIPT_KEY = "AdvancedCoreRewardEligibilityLogin"; + private static final long INITIAL_FALLBACK_NANOS = TimeUnit.SECONDS.toNanos(10); + private static final long MAX_FALLBACK_NANOS = TimeUnit.MINUTES.toNanos(5); + private final LongSupplier nanoClock; + private final AdvancedCorePlugin plugin; + private final RewardHandler handler; + private final LinkedHashMap pending = new LinkedHashMap<>(); + private final LinkedHashMap overflowRunning = new LinkedHashMap<>(); + private final RewardEligibilityOverflow overflow; + private ScheduledFuture fallback; + private boolean closed; + private boolean startupRequested; + + RewardEligibility(AdvancedCorePlugin plugin, RewardHandler handler) { + this(plugin, handler, System::nanoTime); + } + + RewardEligibility(AdvancedCorePlugin plugin, RewardHandler handler, LongSupplier nanoClock) { + this.plugin = plugin; + this.handler = handler; + this.nanoClock = Objects.requireNonNull(nanoClock, "nanoClock"); + this.overflow = new RewardEligibilityOverflow(plugin, this, nanoClock); + } + + /** Initializes only existing native sessions after all startup dependencies are ready. */ + public void bootstrapOnlinePlayers() { + synchronized (this) { + if (closed || startupRequested) return; + startupRequested = true; + } + RewardEligibilityStartup.start(plugin, this); + } + + synchronized boolean isOpen() { return !closed; } + + /** A newly joining native session must finish the ordinary accepted-login path. */ + public void loginPending(Player player) { + synchronized (this) { if (closed) return; } + player.setMetadata(LOGIN_RECEIPT_KEY, new FixedMetadataValue(plugin, Boolean.FALSE)); + } + + /** + * Owner-thread native join/bootstrap for consumers that disable user-data loading. + * Those consumers do not install the ordinary AdvancedCore login listener. Accept + * the native session without resolving a user or emitting a synthetic login event. + */ + public CompletionStage nativeSessionReady(Player player) { + Objects.requireNonNull(player, "player"); + List known = new ArrayList<>(); + synchronized (this) { + if (closed || !plugin.isEnabled() || plugin.isLoadUserData() + || plugin.getRewardHandler() != handler || !player.isOnline()) + return CompletableFuture.completedFuture(null); + for (Pending entry : allPending()) known.add(entry.user); + } + player.setMetadata(LOGIN_RECEIPT_KEY, new FixedMetadataValue(plugin, Boolean.TRUE)); + // Inspect only bounded, already-known identities. getPlayer() uses the + // native registry and temporary name cache; it does not load stored users. + List matching = new ArrayList<>(); + for (AdvancedCoreUser user : known) if (user.getPlayer() == player) matching.add(user); + CompletionStage tail = CompletableFuture.completedFuture(null); + for (AdvancedCoreUser user : matching) { + tail = tail.thenCompose(ignored -> signalLogin(user, player)); + } + return tail; + } + + /** + * Schedules an opted-in player action with explicit Folia retirement handling. + * Callers share their action claim with both terminal callbacks so a late + * task cannot execute after its unstarted work has been retained for retry. + */ + public static void runPlayerTask(AdvancedCorePlugin plugin, Player player, Runnable action, + Runnable retired, Consumer rejected) { + var scheduler = plugin.getBukkitScheduler(); + var folia = scheduler.getFoliaLib(); + if (folia == null || !folia.isFolia()) { + scheduler.runTask(plugin, action, player); + return; + } + var result = folia.getImpl().runAtEntityWithFallback(player, ignored -> action.run(), retired); + if (result == null) throw new IllegalStateException("Entity scheduler returned no admission result"); + result.whenComplete((status, failure) -> { + if (failure != null) rejected.accept(failure); + else if (status == EntityTaskResult.ENTITY_RETIRED || status == EntityTaskResult.SCHEDULER_RETIRED) { + retired.run(); + } else if (status != EntityTaskResult.SUCCESS) { + rejected.accept(new IllegalStateException("Entity scheduler returned an unknown admission result")); + } + }); + } + + /** Retirement proves that the claimed player action did not begin. */ + public static RuntimeException retiredPlayerActionFailure(AdvancedCorePlugin plugin) { + return AdvancedCoreUser.replayActionNotStarted("Player scheduler retired before reward action dispatch", + plugin.isEnabled() ? Reward.playerEligibilityDeferred() : null); + } + + /** Whether this definition opts into any live eligibility requirement. */ + public static boolean isConfigured(ConfigurationSection data) { + return data.contains("GameModes", true) || data.contains("BlackListedGameModes", true); + } + + /** Includes the embedding plugin's optional default for definitions without an allowlist. */ + public static boolean isConfigured(AdvancedCorePlugin plugin, ConfigurationSection data) { + if (isConfigured(data)) return true; + Object defaults = defaultModes(plugin); + return !(defaults instanceof List modes) || !modes.isEmpty(); + } + + private static Object defaultModes(AdvancedCorePlugin plugin) { + var options = plugin.getOptions(); + if (options == null) return List.of(); + var config = options.getYmlConfig(); + // Read the in-memory section so malformed YAML cannot become an empty + // typed list and silently remove the configured restriction. + if (config != null && config.getData() != null + && config.getData().contains("DefaultRewardGameModes", true)) { + return config.getData().get("DefaultRewardGameModes"); + } + var modes = options.getDefaultRewardGameModes(); + return modes == null ? List.of() : modes; + } + + /** Fast deterministic predicate; callers must already hold the player owner. */ + public boolean isEligible(ConfigurationSection data, AdvancedCoreUser user) { + if (!isConfigured(plugin, data)) return true; + Object allowedValue = data.contains("GameModes", true) ? data.get("GameModes") : defaultModes(plugin); + if (!validModeList(allowedValue) || !validModes(data, "BlackListedGameModes")) return false; + Pending observed; + synchronized (this) { + observed = findPending(user.getUUID()); + if (closed || observed != null && observed.awaitingLogin) return false; + } + if (observed != null) { + Player current = user.getPlayer(); + synchronized (this) { + if (closed || findPending(user.getUUID()) != observed || observed.awaitingLogin + || observed.player == null || observed.player != current) return false; + } + } + List allowed = (List) allowedValue; + List denied = data.getStringList("BlackListedGameModes"); + if (allowed.isEmpty() && denied.isEmpty() && !overflow.enabled()) return true; + Player player = user.getPlayer(); + if (player == null || !player.isOnline()) return false; + if (player.getMetadata(LOGIN_RECEIPT_KEY).stream().anyMatch(value -> + value.getOwningPlugin() == plugin && Boolean.FALSE.equals(value.value()))) return false; + if (overflow.enabled() && !acceptedSession(player)) return false; + GameMode mode = player.getGameMode(); + if ((!allowed.isEmpty() && !containsMode(allowed, mode)) || containsMode(denied, mode)) return false; + return true; + } + + public static boolean validModes(ConfigurationSection data, String path) { + if (!data.contains(path, true)) return true; + return validModeList(data.get(path)); + } + + private static boolean validModeList(Object value) { + if (!(value instanceof List values) || values.size() > GameMode.values().length) return false; + for (Object entry : values) { + if (!(entry instanceof String name)) return false; + try { GameMode.valueOf(name.trim().toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException failure) { return false; } + } + return true; + } + + private static boolean containsMode(List modes, GameMode mode) { + for (Object candidate : modes) if (mode.name().equalsIgnoreCase(((String) candidate).trim())) return true; + return false; + } + + /** Records only a wake-up hint. The existing persistent queue remains authoritative. Owner-thread only. */ + void deferred(AdvancedCoreUser user) { + Player player = user.getPlayer(); + synchronized (this) { + Pending current = findPending(user.getUUID()); + if (closed || current != null && (current.awaitingLogin || current.player != player)) return; + } + if (player == null || !player.isOnline()) return; + boolean accepted = acceptedSession(player); + synchronized (this) { + if (closed) return; + Pending current = findPending(user.getUUID()); + // Only the accepted login or receipt check may bind an unknown session. + if (current != null && (current.awaitingLogin || current.player != player)) return; + if (current == null) { + if (pending.size() >= MAX_PENDING_PLAYERS) { overflow.enable(); startFallback(); return; } + current = new Pending(user, accepted ? player : null, nanoClock.getAsLong()); + pending.put(user.getUUID(), current); + } + current.user = user; + current.player = accepted ? player : null; + current.awaitingLogin = !accepted; + current.revision++; + startFallback(); + } + } + + /** Publication can race a signal that observed an empty queue; renew the wake-up afterwards. */ + void queued(AdvancedCoreUser user) { + retainHint(user); + } + + /** + * Retains a bounded automatic wake-up for an already-due timed occurrence. + * A false result requires the caller to keep its ordinary future retry deadline + * and timer. This worker-safe hint never owns or completes the stored reward. + */ + public synchronized boolean retainTimedRetry(AdvancedCoreUser user) { + Objects.requireNonNull(user, "user"); + if (user.getPlugin() != plugin) throw new IllegalArgumentException("User belongs to another plugin"); + return plugin.isEnabled() && retainHint(user) && fallback != null + && !fallback.isCancelled() && !fallback.isDone(); + } + + private synchronized boolean retainHint(AdvancedCoreUser user) { + if (closed) return false; + Pending current = findPending(user.getUUID()); + if (current == null) { + if (pending.size() >= MAX_PENDING_PLAYERS) { overflow.enable(); startFallback(); return false; } + current = new Pending(user, null, nanoClock.getAsLong()); + pending.put(user.getUUID(), current); + } + current.revision++; + startFallback(); + return true; + } + + /** + * Rechecks the existing offline queue and completes after its claimed occurrences + * finish or defer again. Concurrent signals for one logical user share one stage. + * This method accepts the consumer's user identity, including offline-mode identities. + */ + public CompletionStage signal(AdvancedCoreUser user) { + return signal(user, null); + } + + private Pending findPending(String uuid) { + Pending current = pending.get(uuid); + return current == null ? overflowRunning.get(uuid) : current; + } + + boolean acceptedSession(Player player) { + return player.getMetadata(LOGIN_RECEIPT_KEY).stream().anyMatch(value -> + value.getOwningPlugin() == plugin && Boolean.TRUE.equals(value.value())); + } + + /** Called only after exact native-session validation on its owner. */ + CompletionStage signalOverflow(AdvancedCoreUser user, Player player) { + if (user.getPlugin() != plugin) return CompletableFuture.failedFuture( + new IllegalArgumentException("User belongs to another plugin")); + synchronized (this) { + if (closed || !plugin.isEnabled()) return CompletableFuture.completedFuture(null); + Pending current = findPending(user.getUUID()); + if (current == null) { + if (overflowRunning.size() >= RewardEligibilityOverflow.BATCH) + return CompletableFuture.completedFuture(null); + current = new Pending(user, player, nanoClock.getAsLong()); + overflowRunning.put(user.getUUID(), current); + } + current.user = user; + current.player = player; + current.awaitingLogin = false; + current.revision++; + } + return signal(user); + } + + private CompletionStage signal(AdvancedCoreUser user, Pending fallbackEntry) { + Objects.requireNonNull(user, "user"); + if (user.getPlugin() != plugin) return CompletableFuture.failedFuture( + new IllegalArgumentException("User belongs to another plugin")); + Pending current; + long revision; + synchronized (this) { + if (closed || !plugin.isEnabled()) return CompletableFuture.failedFuture( + new IllegalStateException("Reward eligibility is closed")); + current = findPending(user.getUUID()); + if (current != null && current.awaitingLogin + && overflowRunning.get(user.getUUID()) != current) return CompletableFuture.completedFuture(null); + long now = nanoClock.getAsLong(); + if (fallbackEntry != null && (current != fallbackEntry || current.running != null + || now - current.nextSweepNanos < 0)) return CompletableFuture.completedFuture(null); + if (current == null) { + if (pending.size() >= MAX_PENDING_PLAYERS) return CompletableFuture.failedFuture( + new IllegalStateException("Too many pending reward eligibility checks")); + current = new Pending(user, null, nanoClock.getAsLong()); + pending.put(user.getUUID(), current); + } + if (fallbackEntry == null) { + current.fallbackDelayNanos = INITIAL_FALLBACK_NANOS; + } else { + current.fallbackDelayNanos = Math.min(MAX_FALLBACK_NANOS, current.fallbackDelayNanos * 2); + } + current.nextSweepNanos = now + current.fallbackDelayNanos; + if (current.running != null) return current.running; + current.running = new CompletableFuture<>(); + revision = current.revision; + startFallback(); + } + Pending captured = current; + CompletableFuture result = current.running; + // Replay claims use the storage monitor. Observe them on the worker, + // including when completion originates from a player-owned callback. + try { + plugin.getTimer().execute(() -> runSignalCheck(user, captured, revision, result)); + } catch (Throwable failure) { + finishSignal(user, captured, revision, result, false, failure); + } + return result; + } + + private void runSignalCheck(AdvancedCoreUser user, Pending captured, long revision, + CompletableFuture result) { + boolean awaitingLogin; + boolean needsBinding; + boolean awaitReplaySettlement; + synchronized (this) { + if (closed || findPending(user.getUUID()) != captured || captured.running != result) return; + awaitingLogin = captured.awaitingLogin; + needsBinding = captured.player == null; + awaitReplaySettlement = captured.awaitReplaySettlement; + } + if (awaitingLogin) { + // Overflow admissions are temporary execution gates. They may retire + // after claims finish; online discovery remains the automatic wake-up. + boolean retire = false; + try { + synchronized (this) { retire = overflowRunning.get(user.getUUID()) == captured; } + retire = retire && !user.hasPendingRewardReplay(); + finishSignal(user, captured, revision, result, retire, null); + } catch (Throwable failure) { finishSignal(user, captured, revision, result, false, failure); } + return; + } + if (needsBinding) { + resolveSignalPlayer(user, captured, revision, result); + return; + } + final boolean claimedBeforeCheck; + final CompletionStage check; + try { + if (awaitReplaySettlement && user.hasPendingRewardReplay()) { + // One waiter per bounded Pending entry. Wait for claim release, + // not just reward outcome: the latter can precede restoration. + user.pendingRewardReplayCompletion().whenComplete((ignored, failure) -> { + if (failure != null) { + finishSignal(user, captured, revision, result, false, failure); + return; + } + try { + plugin.getTimer().execute(() -> runSignalCheck(user, captured, revision, result)); + } catch (Throwable rejected) { + finishSignal(user, captured, revision, result, false, rejected); + } + }); + return; + } + claimedBeforeCheck = user.hasPendingRewardReplay(); + user.checkDelayedTimedRewards(); + check = user.checkOfflineRewardsAsync(); + if (check == null) throw new IllegalStateException("Offline replay returned no completion stage"); + } catch (Throwable failure) { + finishSignal(user, captured, revision, result, false, failure); + return; + } + check.whenComplete((ignored, failure) -> { + try { + plugin.getTimer().execute(() -> { + boolean canRetireHint = false; + Throwable outcome = failure; + try { + canRetireHint = !user.hasPendingRewardReplay() && !claimedBeforeCheck; + } catch (Throwable observationFailure) { + if (outcome == null) outcome = observationFailure; + } + finishSignal(user, captured, revision, result, canRetireHint, outcome); + }); + } catch (Throwable rejected) { + finishSignal(user, captured, revision, result, false, failure == null ? rejected : failure); + } + }); + } + + /** Unknown worker-published hints require proof before any queue/storage probe. */ + private void resolveSignalPlayer(AdvancedCoreUser user, Pending captured, long revision, + CompletableFuture result) { + Reward.continueOnResolvedPlayerThread(plugin, user, player -> { + synchronized (this) { + if (closed || findPending(user.getUUID()) != captured || captured.running != result + || captured.awaitingLogin) return CompletableFuture.completedFuture(false); + if (captured.player != null) return CompletableFuture.completedFuture(true); + } + boolean accepted = player != null && user.getPlayer() == player && player.isOnline() + && player.getMetadata(LOGIN_RECEIPT_KEY).stream().anyMatch(value -> + value.getOwningPlugin() == plugin && Boolean.TRUE.equals(value.value())); + synchronized (this) { + if (closed || findPending(user.getUUID()) != captured || captured.running != result + || captured.awaitingLogin) return CompletableFuture.completedFuture(false); + // A newer accepted login may have rebound the hint while this task waited. + if (captured.player != null) return CompletableFuture.completedFuture(true); + if (accepted) captured.player = player; + else { + captured.awaitingLogin = true; + captured.revision++; + } + } + return CompletableFuture.completedFuture(accepted); + }).whenComplete((accepted, failure) -> { + if (failure != null || !Boolean.TRUE.equals(accepted)) { + finishSignal(user, captured, revision, result, false, failure); + return; + } + try { plugin.getTimer().execute(() -> runSignalCheck(user, captured, revision, result)); } + catch (Throwable rejected) { finishSignal(user, captured, revision, result, false, rejected); } + }); + } + + private void finishSignal(AdvancedCoreUser user, Pending captured, long revision, + CompletableFuture result, boolean canRetireHint, Throwable failure) { + synchronized (this) { + if (findPending(user.getUUID()) == captured && captured.running == result) { + captured.running = null; + if (failure == null && canRetireHint && overflowRunning.get(user.getUUID()) == captured) + overflowRunning.remove(user.getUUID()); + else if (failure == null && canRetireHint && captured.revision == revision) + pending.remove(user.getUUID()); + else startFallback(); + stopIdleFallback(); + } + } + if (failure == null) result.complete(null); else result.completeExceptionally(failure); + } + + /** Native events wake only deferred users and retain their original logical identity. */ + public void signal(Player player) { + List users = new ArrayList<>(); + synchronized (this) { + for (Pending entry : pending.values()) if (entry.player == player) users.add(entry.user); + } + for (AdvancedCoreUser user : users) signal(user); + if (overflow.enabled()) overflow.signal(player); + } + + /** + * The logical hint may be the sole automatic wake-up for due timed work. + * Retain it across quit without polling storage or keeping a native player. + */ + public void disconnected(Player player) { + player.removeMetadata(LOGIN_RECEIPT_KEY, plugin); + synchronized (this) { + for (Pending entry : allPending()) { + if (entry.player == player) { + entry.player = null; + entry.awaitingLogin = true; + entry.revision++; + } + } + } + } + + /** Rebinds only known work after the resolved, authenticated login event. */ + public CompletionStage signalLogin(AdvancedCoreUser user, Player player) { + Objects.requireNonNull(user, "user"); + Objects.requireNonNull(player, "player"); + if (user.getPlugin() != plugin) return CompletableFuture.failedFuture( + new IllegalArgumentException("User belongs to another plugin")); + synchronized (this) { + if (closed || !plugin.isEnabled()) return CompletableFuture.failedFuture( + new IllegalStateException("Reward eligibility is closed")); + } + // Login events are asynchronous and can outlive their connection. Validate + // on that exact player's owner, rather than scheduling a replacement session. + return Reward.continueOnPlayerThread(plugin, player, () -> { + if (user.getPlayer() != player || !player.isOnline()) + return CompletableFuture.completedFuture(null); + synchronized (this) { if (closed) return CompletableFuture.completedFuture(null); } + // Keep the receipt on the native session even if its work is published later. + player.setMetadata(LOGIN_RECEIPT_KEY, new FixedMetadataValue(plugin, Boolean.TRUE)); + synchronized (this) { + if (closed) return CompletableFuture.completedFuture(null); + Pending current = findPending(user.getUUID()); + if (current == null && !overflow.enabled()) return CompletableFuture.completedFuture(null); + if (current != null) { + current.user = user; + current.player = player; + current.awaitingLogin = false; + current.awaitReplaySettlement = true; + current.revision++; + } + } + return overflow.enabled() ? signalOverflow(user, player) : signal(user); + }); + } + + private void sweep() { + List batch = new ArrayList<>(); + synchronized (this) { + if (closed || !plugin.isEnabled()) return; + var iterator = pending.entrySet().iterator(); + while (iterator.hasNext() && batch.size() < FALLBACK_BATCH) { + batch.add(iterator.next().getValue()); + iterator.remove(); + } + // Rotate bounded hints to give every pending player a turn. + for (Pending entry : batch) pending.put(entry.user.getUUID(), entry); + } + List temporary; + synchronized (this) { temporary = new ArrayList<>(overflowRunning.values()); } + for (Pending entry : batch) signal(entry.user, entry); + for (Pending entry : temporary) signal(entry.user, entry); + overflow.sweep(); + } + + private List allPending() { + List entries = new ArrayList<>(pending.values()); + entries.addAll(overflowRunning.values()); + return entries; + } + + private void stopIdleFallback() { + if (pending.isEmpty() && overflowRunning.isEmpty() && !overflow.enabled() && fallback != null) { fallback.cancel(false); fallback = null; } + } + + private void startFallback() { + if (fallback != null) return; + try { + fallback = handler.getDelayedTimer().scheduleWithFixedDelay(this::sweep, 10, 10, TimeUnit.SECONDS); + } catch (RuntimeException failure) { + // Explicit signals and login replay remain available after scheduler retirement. + } + } + + public void close() { + List> running = new ArrayList<>(); + synchronized (this) { + closed = true; + if (fallback != null) fallback.cancel(false); + fallback = null; + for (Pending entry : allPending()) if (entry.running != null) running.add(entry.running); + pending.clear(); + overflowRunning.clear(); + } + overflow.close(); + for (CompletableFuture result : running) result.completeExceptionally( + new IllegalStateException("Reward eligibility closed before replay completed")); + } + + private static final class Pending { + private AdvancedCoreUser user; + private Player player; + private long revision; + private boolean awaitingLogin; + private boolean awaitReplaySettlement; + private CompletableFuture running; + private long fallbackDelayNanos = INITIAL_FALLBACK_NANOS; + private long nextSweepNanos; + private Pending(AdvancedCoreUser user, Player player, long now) { + this.user = user; + this.player = player; + this.nextSweepNanos = now + INITIAL_FALLBACK_NANOS; + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityOverflow.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityOverflow.java new file mode 100644 index 0000000000..01251add62 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityOverflow.java @@ -0,0 +1,148 @@ +package com.bencodez.advancedcore.api.rewards; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import com.bencodez.advancedcore.AdvancedCorePlugin; + +/** Bounded discovery after hint saturation; it never stores reward payloads. */ +final class RewardEligibilityOverflow { + static final int BATCH = 128; + private final AdvancedCorePlugin plugin; + private final RewardEligibility eligibility; + private final LongSupplier clock; + private final IdentityHashMap> active = new IdentityHashMap<>(); + private List snapshot = List.of(); + private int position; + private volatile boolean enabled; + private boolean closed; + private boolean scanning; + private boolean reported; + private long delay = TimeUnit.SECONDS.toNanos(10); + private long nextSweep; + + RewardEligibilityOverflow(AdvancedCorePlugin plugin, RewardEligibility eligibility, LongSupplier clock) { + this.plugin = plugin; + this.eligibility = eligibility; + this.clock = clock; + } + + synchronized void enable() { + if (!closed && !enabled) { + enabled = true; + nextSweep = clock.getAsLong() + delay; + } + } + + boolean enabled() { return enabled; } + + CompletionStage signal(Player player) { + CompletableFuture result; + synchronized (this) { + if (closed || !enabled) return CompletableFuture.completedFuture(null); + result = active.get(player); + if (result != null) return result; + // Admission exhaustion is retried by rotating discovery, not forgotten. + if (active.size() >= BATCH) return CompletableFuture.completedFuture(null); + result = new CompletableFuture<>(); + active.put(player, result); + } + CompletableFuture captured = result; + result.whenComplete((ignored, failure) -> { + synchronized (this) { active.remove(player, captured); } + if (failure != null) reportFailure(); + }); + try { + Reward.continueOnPlayerThread(plugin, player, () -> { + if (!live() || !player.isOnline() || !eligibility.acceptedSession(player)) + return CompletableFuture.completedFuture(null); + String name = player.getName(); + CompletableFuture user = new CompletableFuture<>(); + // Name-based resolution preserves the configured offline-mode identity. + plugin.getTimer().execute(() -> { + try { + if (!live()) { user.complete(null); return; } + plugin.getUserManager().getUserAsync(name, user::complete, user::completeExceptionally); + } catch (RuntimeException failure) { user.completeExceptionally(failure); } + }); + return user.thenCompose(loaded -> Reward.continueOnPlayerThread(plugin, player, () -> { + if (!live() || loaded == null || loaded.getPlayer() != player || !player.isOnline() + || !eligibility.acceptedSession(player)) return CompletableFuture.completedFuture(null); + return eligibility.signalOverflow(loaded, player); + })); + }).whenComplete((ignored, failure) -> { + if (failure == null) captured.complete(null); else captured.completeExceptionally(failure); + }); + } catch (RuntimeException failure) { captured.completeExceptionally(failure); } + return result; + } + + void sweep() { + synchronized (this) { + if (closed || !enabled || scanning || clock.getAsLong() - nextSweep < 0) return; + scanning = true; + nextSweep = clock.getAsLong() + delay; + delay = Math.min(TimeUnit.MINUTES.toNanos(5), delay * 2); + } + try { + plugin.getBukkitScheduler().executeOrScheduleSync(plugin, () -> { + try { + if (!live()) { finishScan(); return; } + // Hold one online-population snapshot across passes. New arrivals + // cannot continually displace older members before their turn. + List fresh = null; + synchronized (this) { if (position == snapshot.size()) fresh = new ArrayList<>(); } + if (fresh != null) fresh.addAll(Bukkit.getOnlinePlayers()); + List batch; + synchronized (this) { + if (closed) { scanning = false; return; } + if (fresh != null) { snapshot = fresh; position = 0; } + int end = Math.min(snapshot.size(), position + BATCH); + batch = new ArrayList<>(snapshot.subList(position, end)); + position = end; + if (position == snapshot.size()) { snapshot = List.of(); position = 0; } + } + // Each signal has its own bounded physical admission. A pending + // resolution/replay consumes that slot, not the entire scan lane. + for (Player player : batch) signal(player); + finishScan(); + } catch (RuntimeException failure) { reportFailure(); finishScan(); } + }); + } catch (RuntimeException failure) { reportFailure(); finishScan(); } + } + + private boolean live() { + synchronized (this) { if (closed) return false; } + return plugin.isEnabled() && eligibility.isOpen() && plugin.getRewardHandler() != null + && plugin.getRewardHandler().getRewardEligibility() == eligibility; + } + + private synchronized void finishScan() { scanning = false; } + + private void reportFailure() { + synchronized (this) { + if (closed || reported) return; + reported = true; + } + plugin.debug("An overflow reward eligibility check could not complete; durable work remains pending"); + } + + void close() { + List> running; + synchronized (this) { + closed = true; + snapshot = List.of(); + running = new ArrayList<>(active.values()); + active.clear(); + } + for (var result : running) result.completeExceptionally(new IllegalStateException("Reward eligibility closed")); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartup.java new file mode 100644 index 0000000000..551ab7cba9 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartup.java @@ -0,0 +1,131 @@ +package com.bencodez.advancedcore.api.rewards; + +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import fr.xephi.authme.api.v3.AuthMeApi; +import com.nickuc.login.api.nLoginAPI; +import com.lenis0012.bukkit.loginsecurity.LoginSecurity; + +/** One sequential, session-bound bootstrap; it does not fire generic login events. */ +final class RewardEligibilityStartup { + static final String RECEIPT = "AdvancedCoreRewardEligibilityLogin"; + private RewardEligibilityStartup() { } + + static void start(AdvancedCorePlugin plugin, RewardEligibility eligibility) { + AtomicBoolean reported = new AtomicBoolean(); + AtomicBoolean queryInFlight = new AtomicBoolean(); + plugin.getBukkitScheduler().executeOrScheduleSync(plugin, () -> { + if (!current(plugin, eligibility)) return; + // Retention is bounded by the current native online population. Only one + // player is in flight; this does not enqueue a task per player in a burst. + CompletionStage tail = CompletableFuture.completedFuture(null); + for (Player player : new ArrayList<>(Bukkit.getOnlinePlayers())) { + tail = tail.handle((ignored, failure) -> { + if (failure != null && reported.compareAndSet(false, true)) + plugin.debug("An existing online reward session could not be initialized"); + return null; + }).thenCompose(ignored -> probe(plugin, eligibility, player, queryInFlight)); + } + tail.whenComplete((ignored, failure) -> { + if (failure != null && reported.compareAndSet(false, true)) + plugin.debug("An existing online reward session could not be initialized"); + }); + }); + } + + private static CompletionStage probe(AdvancedCorePlugin plugin, RewardEligibility eligibility, Player player, + AtomicBoolean queryInFlight) { + return Reward.continueOnPlayerThread(plugin, player, () -> { + if (!current(plugin, eligibility) || !player.isOnline()) + return CompletableFuture.completedFuture(null); + if (!plugin.isLoadUserData()) return eligibility.nativeSessionReady(player); + if (pendingLogin(plugin, player)) + return CompletableFuture.completedFuture(null); + String name = player.getName(); + boolean wait = plugin.getOptions().isWaitUntilLoggedIn(); + // The pinned AuthMe query is a memory-only PlayerCache read. Its native + // player-name access stays on this owner; other providers can load storage. + if (wait && plugin.isAuthMeLoaded() && !AuthMeApi.getInstance().isAuthenticated(player)) + return CompletableFuture.completedFuture(null); + CompletableFuture authentication = authenticate(plugin, eligibility, player, name, wait, queryInFlight); + return authentication.orTimeout(30, TimeUnit.SECONDS).thenCompose(accepted -> { + if (!accepted || !current(plugin, eligibility)) return CompletableFuture.completedFuture(null); + CompletableFuture loaded = new CompletableFuture<>(); + // Names retain the existing offline-mode UUID resolution contract. + plugin.getUserManager().getUserAsync(name, loaded::complete, loaded::completeExceptionally); + return loaded.orTimeout(30, TimeUnit.SECONDS).thenCompose(user -> + Reward.continueOnPlayerThread(plugin, player, () -> { + if (!current(plugin, eligibility) || !player.isOnline() || pendingLogin(plugin, player) + || user == null || user.getPlayer() != player) return CompletableFuture.completedFuture(null); + return eligibility.signalLogin(user, player); + })); + }); + }); + } + + /** A timeout retires its result, not a provider operation that may still be blocked. */ + static CompletableFuture authenticate(AdvancedCorePlugin plugin, RewardEligibility eligibility, + Player player, String name, boolean wait, AtomicBoolean queryInFlight) { + CompletableFuture result = new CompletableFuture<>(); + if (!queryInFlight.compareAndSet(false, true)) { + result.completeExceptionally(new IllegalStateException("Existing online authentication query is still running")); + return result; + } + try { + plugin.getTimer().execute(() -> { + boolean accepted = false; + Throwable failure = null; + try { + if (current(plugin, eligibility) && !result.isDone()) { + accepted = true; + if (wait && plugin.isNLoginLoaded()) { + var api = nLoginAPI.getApi(); + accepted = api != null && api.isAvailable() && api.isAuthenticated(name); + } + if (accepted && wait && plugin.isLoginSecurityLoaded()) { + // A session miss may load storage; the native accesses inside + // this optional provider are limited to identity/session getters. + var session = loginSecuritySession(player); + accepted = session != null && session.isAuthorized(); + } + } + } catch (RuntimeException | LinkageError outcome) { + failure = outcome; + } finally { + queryInFlight.set(false); + } + // Complete after releasing physical ownership: normal synchronous + // continuations may immediately begin the next player's probe. + if (failure == null) result.complete(accepted); + else result.completeExceptionally(failure); + }); + } catch (RuntimeException failure) { + queryInFlight.set(false); + result.completeExceptionally(failure); + } + return result; + } + + /** Optional provider boundary; a session miss is allowed to touch its own storage. */ + static com.lenis0012.bukkit.loginsecurity.session.PlayerSession loginSecuritySession(Player player) { + var manager = LoginSecurity.getSessionManager(); + return manager == null ? null : manager.getPlayerSession(player); + } + + private static boolean pendingLogin(AdvancedCorePlugin plugin, Player player) { + return player.getMetadata(RECEIPT).stream().anyMatch(value -> + value.getOwningPlugin() == plugin && Boolean.FALSE.equals(value.value())); + } + + private static boolean current(AdvancedCorePlugin plugin, RewardEligibility eligibility) { + var handler = plugin.getRewardHandler(); + return plugin.isEnabled() && eligibility.isOpen() && handler != null + && handler.getRewardEligibility() == eligibility; + } +} 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 c1c48b5c71..cf9ad558cf 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 @@ -45,6 +45,8 @@ public class RewardHandler { private final RewardInjectionRegistry rewardInjectionRegistry; @Getter private final RewardExecutor rewardExecutor; + @Getter + private final RewardEligibility rewardEligibility; AdvancedCorePlugin plugin; @@ -59,6 +61,7 @@ public RewardHandler(AdvancedCorePlugin plugin) { subRewardResolver = new SubRewardResolver(this, plugin); rewardLoader = new RewardLoader(this, plugin); rewardExecutor = new RewardExecutor(this, plugin); + rewardEligibility = new RewardEligibility(plugin, this); } public void addDirectlyDefined(DirectlyDefinedReward directlyDefinedReward) { @@ -288,6 +291,7 @@ public void setupExample() { } public void shutdown() { + rewardEligibility.close(); delayedTimer.shutdown(); try { delayedTimer.awaitTermination(10, TimeUnit.SECONDS); 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 46882a7ece..3a48c82721 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 @@ -249,6 +249,9 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor ArrayList consoleCommands = (ArrayList) section.getList("Console", new ArrayList<>()); ArrayList userCommands = (ArrayList) section.getList("Player", new ArrayList<>()); boolean stagger = section.getBoolean("Stagger", true); + AdvancedCoreUser.PlayerEligibilityGuard eligibilityGuard = user.capturePlayerEligibilityGuard(); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); boolean playerCommandsPending = Reward.hasReplayCommandSnapshot(placeholders, "player") ? Reward.hasPendingReplayCommandWork(placeholders, "player") : !userCommands.isEmpty(); @@ -260,9 +263,14 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor 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)) + : eligibilityGuard == null + ? MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, + placeholders, stagger) + : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, + placeholders, stagger, eligibilityGuard)) + .thenCompose(ignored -> eligibilityGuard == null + ? user.preformCommandAsync(userCommands, placeholders) + : user.preformCommandAsync(userCommands, placeholders, eligibilityGuard, replayState, replayKey)) .thenApply(ignored -> null); } }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("Commands.Console", null) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/BuiltinRequirements.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/BuiltinRequirements.java index d23d61de6d..f4362e1c18 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/BuiltinRequirements.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/BuiltinRequirements.java @@ -16,6 +16,7 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { RequirementDayOfMonth.register(handler, plugin); RequirementServer.register(handler, plugin); RequirementWorld.register(handler, plugin); + RequirementGameMode.register(handler, plugin); RequirementRewardType.register(handler, plugin); RequirementJavascript.register(handler, plugin); RequirementDate.register(handler, plugin); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementGameMode.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementGameMode.java new file mode 100644 index 0000000000..8619925021 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementGameMode.java @@ -0,0 +1,44 @@ +package com.bencodez.advancedcore.api.rewards.builtin.requirements; + +import org.bukkit.configuration.ConfigurationSection; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardEligibility; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject; +import com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInjectValidator; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; + +/** The deterministic, reattemptable player-context requirements. */ +public final class RequirementGameMode { + private RequirementGameMode() { } + + public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { + RequirementInjectValidator validator = new RequirementInjectValidator() { + @Override + public void onValidate(Reward reward, RequirementInject inject, ConfigurationSection data) { + for (String path : new String[] { "GameModes", "BlackListedGameModes" }) { + if (!RewardEligibility.validModes(data, path)) warning(reward, inject, + path + " must be a list of Bukkit gamemode names"); + } + } + }.addPath("BlackListedGameModes"); + handler.getInjectedRequirements().add(new RequirementInject("GameModes") { + @Override + public boolean requiresPlayerThread() { return true; } + + @Override + public boolean onRequirementRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, + RewardOptions options) { + return reward.checkPlayerEligibility(user, options); + } + + @Override + public void validate(Reward reward, ConfigurationSection data) { + validator.onValidate(reward, this, data); + } + }.priority(100).allowReattempt().alwaysForceNoData().validator(validator)); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementLocationDistance.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementLocationDistance.java index be7b904d1f..505feb7c18 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementLocationDistance.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementLocationDistance.java @@ -27,6 +27,9 @@ private RequirementLocationDistance() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRequirements().add(new RequirementInjectConfigurationSection("LocationDistance") { + @Override + public boolean requiresPlayerThread() { return true; } + @Override public boolean onRequirementsRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, RewardOptions rewardOptions) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementPermission.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementPermission.java index 979a006199..6f0ad70b04 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementPermission.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementPermission.java @@ -2,7 +2,10 @@ import java.util.UUID; +import org.bukkit.Bukkit; import org.bukkit.configuration.ConfigurationSection; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.entity.Player; import com.bencodez.advancedcore.AdvancedCorePlugin; @@ -27,6 +30,33 @@ private RequirementPermission() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRequirements().add(new RequirementInjectString("Permission", "") { + @Override + public CompletionStage onRequirementRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, RewardOptions options) { + if (!appliesTo(data) || !reward.getConfig().getRequirePermission()) { + return CompletableFuture.completedFuture(true); + } + String configured = data.getString(getPath(), getDefaultValue()); + if (configured.isEmpty()) configured = "AdvancedCore.Reward." + reward.getName(); + boolean reverse = configured.startsWith("!"); + String permission = reverse ? configured.substring(1) : configured; + UUID identity = UUID.fromString(user.getUUID()); + return reward.evaluatePlayerRequirement(user, player -> { + if (player != null) { + if (!identity.equals(player.getUniqueId()) || !player.isOnline()) { + return CompletableFuture.completedFuture(false); + } + boolean allowed = player.hasPermission(permission); + return CompletableFuture.completedFuture(reverse ? !allowed : allowed); + } + var worlds = Bukkit.getWorlds(); + String world = worlds.isEmpty() ? null : worlds.get(0).getName(); + return reward.evaluateAsyncRequirement(() -> PlayerManager.getInstance() + .hasOfflineServerPermission(identity, world, permission)) + .thenApply(allowed -> reverse ? !allowed : allowed); + }); + } + @Override public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, String str, RewardOptions rewardOptions) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementWorld.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementWorld.java index 7b7c768d00..7d3aec42a4 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementWorld.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementWorld.java @@ -25,6 +25,17 @@ private RequirementWorld() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRequirements().add(new RequirementInjectStringList("Worlds", new ArrayList<>()) { + @Override + public boolean requiresPlayerThread() { return true; } + + @Override + public void prepareAsyncRequest(Reward reward, ConfigurationSection data, RewardOptions options) { + if (!data.getStringList("Worlds").isEmpty() + || (options.isUseDefaultWorlds() && !plugin.getOptions().getDefaultRewardWorlds().isEmpty())) { + reward.checkRewardFile(); + } + } + @Override public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, ArrayList worlds, RewardOptions rewardOptions) { @@ -38,7 +49,6 @@ public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, Array debug("No player"); return false; } - reward.checkRewardFile(); String world = player.getWorld().getName(); if (plugin.getOptions().getDefaultRewardWorlds().contains(world)) { debug("Player in default whitelisted world: " + world); @@ -54,7 +64,6 @@ public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, Array debug("No player"); return false; } - reward.checkRewardFile(); String world = player.getWorld().getName(); if (worlds.contains(world)) { debug("Player in whitelisted world: " + world); @@ -77,6 +86,17 @@ public void setValue(Player player, ArrayList value) { .validator(listValidator("Worlds"))); handler.getInjectedRequirements().add(new RequirementInjectStringList("BlackListedWorlds", new ArrayList<>()) { + @Override + public boolean requiresPlayerThread() { return true; } + + @Override + public void prepareAsyncRequest(Reward reward, ConfigurationSection data, RewardOptions options) { + if (!data.getStringList("BlackListedWorlds").isEmpty() + || (options.isUseDefaultWorlds() && !plugin.getOptions().getDefaultRewardBlackListedWorlds().isEmpty())) { + reward.checkRewardFile(); + } + } + @Override public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, ArrayList worlds, RewardOptions rewardOptions) { @@ -91,7 +111,6 @@ public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, Array debug("No player"); return false; } - reward.checkRewardFile(); String world = player.getWorld().getName(); if (plugin.getOptions().getDefaultRewardBlackListedWorlds().contains(world)) { user.setCheckWorld(true); @@ -107,7 +126,6 @@ public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, Array debug("No player"); return false; } - reward.checkRewardFile(); String world = player.getWorld().getName(); if (worlds.contains(world)) { user.setCheckWorld(true); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injectedrequirement/RequirementInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injectedrequirement/RequirementInject.java index 0b36000305..5d0732f2ea 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injectedrequirement/RequirementInject.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injectedrequirement/RequirementInject.java @@ -69,6 +69,22 @@ public boolean isEditable() { public abstract boolean onRequirementRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, RewardOptions rewardOptions); + /** Override to split native reads from asynchronous provider work. */ + public java.util.concurrent.CompletionStage onRequirementRequestAsync(Reward reward, + AdvancedCoreUser user, ConfigurationSection data, RewardOptions options) { + return java.util.concurrent.CompletableFuture.completedFuture( + onRequirementRequest(reward, user, data, options)); + } + + /** Native-only predicates opt in; scripts and custom requirements remain on the worker. */ + public boolean requiresPlayerThread() { + return false; + } + + /** Worker preparation before an asynchronous native predicate, including any file work. */ + public void prepareAsyncRequest(Reward reward, ConfigurationSection data, RewardOptions options) { + } + public RequirementInject priority(int priority) { setPriority(priority); return this; diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injectedrequirement/RequirementInjectString.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injectedrequirement/RequirementInjectString.java index a9a49e5d2d..5c28994f23 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injectedrequirement/RequirementInjectString.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injectedrequirement/RequirementInjectString.java @@ -28,8 +28,7 @@ public RequirementInjectString(String path, String defaultValue) { @Override public boolean onRequirementRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, RewardOptions rewardOptions) { - if ((data.isString(getPath()) && !data.getString(getPath(), "").isEmpty()) - || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) { + if (appliesTo(data)) { String value = data.getString(getPath(), getDefaultValue()); AdvancedCorePlugin.getInstance() .extraDebug(reward.getRewardName() + ": Checking " + getPath() + ", value: " + value); @@ -39,6 +38,12 @@ public boolean onRequirementRequest(Reward reward, AdvancedCoreUser user, Config return true; } + /** Same activation rule for synchronous and phased asynchronous implementations. */ + protected final boolean appliesTo(ConfigurationSection data) { + return (data.isString(getPath()) && !data.getString(getPath(), "").isEmpty()) + || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData(); + } + public abstract boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, String value, RewardOptions rewardOptions); 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 40a8f6a43c..32fe185a57 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 @@ -48,6 +48,7 @@ import com.bencodez.advancedcore.api.misc.effects.Title; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardBuilder; +import com.bencodez.advancedcore.api.rewards.RewardEligibility; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; @@ -72,6 +73,7 @@ public class AdvancedCoreUser { 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 ASYNC_REPLAY_KEY_PLACEHOLDER = "__AdvancedCoreAsyncReplayKey"; 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/"; @@ -93,10 +95,14 @@ public static final class AsyncActionCollection { private final String checkpointKey; private final String snapshotKey; private final AdvancedCorePlugin plugin; + private final Supplier playerEligibility; + private final Player resolvedPlayer; + private final boolean playerResolved; private boolean closed; private AsyncActionCollection(AsyncActionCollection previous, AdvancedCoreUser owner, Reward.ReplayState replayState, - HashMap placeholders, String injectionKey, AdvancedCorePlugin plugin) { + HashMap placeholders, String injectionKey, AdvancedCorePlugin plugin, + Supplier playerEligibility, Player resolvedPlayer, boolean playerResolved) { this.previous = previous; this.owner = owner; this.replayState = replayState; @@ -104,6 +110,13 @@ private AsyncActionCollection(AsyncActionCollection previous, AdvancedCoreUser o this.checkpointKey = Reward.legacyActionReplayKey(injectionKey); this.snapshotKey = checkpointKey + ACTION_SNAPSHOT_SUFFIX; this.plugin = plugin; + this.playerEligibility = playerEligibility; + this.resolvedPlayer = resolvedPlayer; + this.playerResolved = playerResolved; + } + + private boolean isPlayerEligible() { + return playerEligibility == null || Boolean.TRUE.equals(playerEligibility.get()); } private synchronized boolean add(Supplier> action, String descriptor) { @@ -374,8 +387,24 @@ public AsyncActionCollection beginAsyncActionCollection() { /** Starts a collection whose legacy actions advance the current replay checkpoint individually. */ public AsyncActionCollection beginAsyncActionCollection(Reward.ReplayState replayState, HashMap placeholders, String injectionKey) { + return beginAsyncActionCollection(replayState, placeholders, injectionKey, null); + } + + /** Starts a collection with a final owner-thread eligibility check for player mutations. */ + public AsyncActionCollection beginAsyncActionCollection(Reward.ReplayState replayState, + HashMap placeholders, String injectionKey, Supplier playerEligibility) { AsyncActionCollection collection = new AsyncActionCollection(ASYNC_ACTION_COLLECTION.get(), this, replayState, - placeholders, injectionKey, plugin); + placeholders, injectionKey, plugin, playerEligibility, null, false); + ASYNC_ACTION_COLLECTION.set(collection); + return collection; + } + + /** Carries an already owner-resolved native session into a reward worker callback. */ + public AsyncActionCollection beginAsyncActionCollection(Reward.ReplayState replayState, + HashMap placeholders, String injectionKey, Supplier playerEligibility, + Player resolvedPlayer) { + AsyncActionCollection collection = new AsyncActionCollection(ASYNC_ACTION_COLLECTION.get(), this, replayState, + placeholders, injectionKey, plugin, playerEligibility, resolvedPlayer, true); ASYNC_ACTION_COLLECTION.set(collection); return collection; } @@ -390,6 +419,81 @@ public AsyncActionContext captureAsyncActionContext() { return new AsyncActionContext(collection != null && collection.belongsTo(this) ? collection : null); } + /** Captures the current reward's optional player policy before its injection scope returns. */ + public static PlayerEligibilityGuard captureCurrentPlayerEligibilityGuard() { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + return collection == null || collection.playerEligibility == null ? null + : new PlayerEligibilityGuard(collection.owner, + collection.playerResolved ? collection.resolvedPlayer : collection.owner.getPlayer(), + collection::isPlayerEligible); + } + + /** Captures only this user's active reward policy; ordinary callers receive no guard. */ + public PlayerEligibilityGuard capturePlayerEligibilityGuard() { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + return collection != null && collection.belongsTo(this) ? captureCurrentPlayerEligibilityGuard() : null; + } + + /** + * Explicitly carries an optional reward policy across deferred actions. + * Player state is inspected only by the player-owned callback. Global actions + * must subsequently return to the global scheduler, so that handoff is not an + * atomic check-and-dispatch operation on Folia. + */ + public static final class PlayerEligibilityGuard { + private final AdvancedCoreUser owner; + private final Player player; + private final Supplier eligibility; + + private PlayerEligibilityGuard(AdvancedCoreUser owner, Player player, Supplier eligibility) { + this.owner = owner; + this.player = player; + this.eligibility = eligibility; + } + + /** Must be invoked on the captured player's owning scheduler. */ + public void checkOnPlayerThread() { + if (!owner.plugin.isEnabled()) throw new IllegalStateException("Plugin disabled before reward action dispatch"); + if (player == null) throw Reward.playerEligibilityDeferred(); + owner.validateLiveScheduledPlayer(player, true); + if (!Boolean.TRUE.equals(eligibility.get())) throw Reward.playerEligibilityDeferred(); + } + + /** Checks on the player owner without accessing entity state on a global callback. */ + public CompletionStage checkAsync() { + if (player == null) return CompletableFuture.failedFuture(Reward.playerEligibilityDeferred()); + CompletableFuture completion = new CompletableFuture<>(); + AtomicBoolean claimed = new AtomicBoolean(); + Runnable check = () -> { + if (!claimed.compareAndSet(false, true)) return; + try { + checkOnPlayerThread(); + completion.complete(null); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }; + try { + RewardEligibility.runPlayerTask(owner.plugin, player, check, + () -> { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + RewardEligibility.retiredPlayerActionFailure(owner.plugin)); + }, failure -> { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + replayActionNotStarted("Scheduler rejected player eligibility check before dispatch", failure)); + }); + } catch (Throwable failure) { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + replayActionNotStarted("Scheduler rejected player eligibility check before dispatch", failure)); + } + CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS).execute(() -> { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + new TimeoutException("Timed out waiting for player reward eligibility")); + }); + return completion; + } + } + /** 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; @@ -439,6 +543,7 @@ private static final class ReplayClaims { private final HashMap offline = new HashMap<>(); private final HashSet timed = new HashSet<>(); private CompletableFuture serialReplayTail = CompletableFuture.completedFuture(null); + private final CompletableFuture settled = new CompletableFuture<>(); } /** @@ -652,6 +757,8 @@ public void addOfflineRewards(Reward reward, HashMap placeholder HashMap savedPlaceholders = placeholders == null ? new HashMap<>() : new HashMap<>(placeholders); if (options != null) savedPlaceholders.putAll(options.getPlaceholders()); + if (options != null && options.getAsyncReplayKey() != null) + savedPlaceholders.put(ASYNC_REPLAY_KEY_PLACEHOLDER, options.getAsyncReplayKey()); String queued = queuedRewardReference(reward, options) + "%placeholders%" + ArrayUtils.makeString(savedPlaceholders); String occurrence = options == null ? null : options.getAsyncReplayOccurrenceId(); @@ -925,11 +1032,14 @@ private static String withAsyncReplayProgress(String rewardEntry, Throwable fail 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()); + // A nondeterministic decision may be durable work before the first effect. + // Preserve its snapshot without inventing a completed-stage marker. + if (completed <= 0 && serializedProgress.isEmpty()) return replayFailure == null + ? rewardEntry : storedReference + suffix; QueuedReplay queuedReplay = parseQueuedReplay(stripAsyncRetryMarker(storedReference)); if (!serializedProgress.isEmpty()) return queuedReference(queuedReplay) + ASYNC_PROGRESS_DELIMITER + serializedProgress + suffix; @@ -1102,6 +1212,8 @@ private void checkDelayedTimedRewards(ReplayPlayerState capturedState) { } RewardOptions replayOptions = new RewardOptions().setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholders)); + String replayKey = replayOptions.getPlaceholders().remove(ASYNC_REPLAY_KEY_PLACEHOLDER); + if (replayKey != null) replayOptions.setAsyncReplayKey(replayKey); if (capturedState != null) replayOptions.captureLivePlayerState( capturedState.online(), capturedState.vanished()); replayOptions.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); @@ -1151,6 +1263,69 @@ public void checkOfflineRewards() { checkOfflineRewards(null); } + /** + * Captures live state on the player owner, then checks the existing durable + * queue on a storage worker. Completion covers the occurrences claimed by + * this pass; a concurrent replay may already own other pending occurrences. + */ + public CompletionStage checkOfflineRewardsAsync() { + return Reward.continueOnServerThread(plugin, this, + () -> CompletableFuture.completedFuture(captureReplayPlayerState())).thenCompose(state -> { + CompletableFuture> handoff = new CompletableFuture<>(); + Runnable work = () -> { + CompletableFuture outcome = new CompletableFuture<>(); + if (!handoff.complete(outcome)) return; + try { + if (!plugin.isEnabled()) throw new IllegalStateException("Plugin disabled before offline replay"); + if (!plugin.getOptions().isProcessRewards()) { outcome.complete(null); return; } + if (isCheckWorld()) setCheckWorld(false); + dispatchOfflineRewards(false, state).whenComplete((ignored, failure) -> { + if (failure == null) outcome.complete(null); else outcome.completeExceptionally(failure); + }); + } catch (Throwable failure) { outcome.completeExceptionally(failure); } + }; + try { + // Runtime admission and queue storage must also stay off the owner. + plugin.getTimer().execute(() -> { + try { + UserDataManager manager = sharedReplayDataManager(); + if (manager == null || !manager.deferSharedStorageWork(work)) work.run(); + } catch (Throwable failure) { handoff.completeExceptionally(failure); } + }); + } catch (Throwable failure) { handoff.completeExceptionally(failure); } + return handoff.orTimeout(30, TimeUnit.SECONDS).thenCompose(stage -> stage); + }); + } + + /** Snapshot of local replay ownership only; never reads user storage. */ + public boolean hasPendingRewardReplay() { + synchronized (plugin) { + synchronized (REPLAY_CLAIMS_LOCK) { + HashMap users = REPLAY_CLAIMS.get(plugin); + ReplayClaims claims = users == null ? null : users.get(getUUID()); + return claims != null && (!claims.offline.isEmpty() || !claims.timed.isEmpty() + || !claims.serialReplayTail.isDone()); + } + } + } + + /** + * Completion of currently owned local replay work, including claim release. + * Does not read storage, await a thread or include claims acquired after this + * ownership generation retires. Callers must revalidate their own session. + */ + public CompletionStage pendingRewardReplayCompletion() { + synchronized (plugin) { + synchronized (REPLAY_CLAIMS_LOCK) { + HashMap users = REPLAY_CLAIMS.get(plugin); + ReplayClaims claims = users == null ? null : users.get(getUUID()); + return claims == null || (claims.offline.isEmpty() && claims.timed.isEmpty() + && claims.serialReplayTail.isDone()) ? CompletableFuture.completedFuture(null) + : claims.settled.minimalCompletionStage(); + } + } + } + private void checkOfflineRewards(ReplayPlayerState capturedState) { if (!plugin.getOptions().isProcessRewards()) { plugin.debug("Processing rewards is disabled"); @@ -1189,6 +1364,8 @@ private CompletionStage dispatchOfflineRewards(boolean force, ReplayPlayer RewardOptions options = new RewardOptions().setOnline(false).setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholderStr)); + String replayKey = options.getPlaceholders().remove(ASYNC_REPLAY_KEY_PLACEHOLDER); + if (replayKey != null) options.setAsyncReplayKey(replayKey); if (capturedState != null) options.captureLivePlayerState( capturedState.online(), capturedState.vanished()); if (force) options.setGiveOffline(false).forceOffline(); @@ -1395,6 +1572,9 @@ private void checkpointTimedReward(AtomicReference currentEntry, long ti /** Restores a due timed entry after an asynchronous replay fails. */ private void restoreTimedReward(String rewardEntry, long time, Throwable failure) { + boolean eligibilityDeferred = Reward.isPlayerEligibilityDeferred(failure); + boolean retainedEligibilityRetry = eligibilityDeferred + && plugin.getRewardHandler().getRewardEligibility().retainTimedRetry(this); long retryTime; synchronized (plugin) { ReplayClaims claims = replayClaims(); @@ -1419,15 +1599,20 @@ private void restoreTimedReward(String rewardEntry, long time, Throwable failure 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); + pending.put(withAsyncRetryCount(withAsyncReplayProgress(restoredEntry, failure), retry), + retainedEligibilityRetry ? time : 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()); + // Accepted eligibility retries share the bounded fallback. A separate timer + // is needed only when that owner cannot retain the hint or this is another failure. + if (!retainedEligibilityRetry) loadTimedDelayedTimer(retryTime); + if (eligibilityDeferred) { + plugin.debug("Queued timed reward remains deferred by player eligibility for " + getPlayerName()); + } else { + plugin.getLogger().warning("Could not deliver queued timed reward for " + getPlayerName() + + "; it will be retried: " + failure.getMessage()); + } } private ReplayClaims replayClaims() { @@ -1450,6 +1635,7 @@ private void releaseReplayClaimsIfEmpty(ReplayClaims claims) { if (byUser == null || byUser.get(getUUID()) != claims) return; byUser.remove(getUUID()); if (byUser.isEmpty()) REPLAY_CLAIMS.remove(plugin); + claims.settled.complete(null); } } } @@ -1964,9 +2150,16 @@ private void scheduleLegacyItemAction(Player player, ItemStack... 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())) { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + java.util.function.BooleanSupplier eligibility = collection != null && collection.belongsTo(this) + && collection.playerEligibility != null ? collection::isPlayerEligible : null; + if (collectAsyncAction(() -> { + if (player == null) return CompletableFuture.failedFuture( + replayActionNotStarted("Player became unavailable before item reward delivery", + eligibility != null && plugin.isEnabled() ? Reward.playerEligibilityDeferred() : null)); + return eligibility == null ? plugin.getFullInventoryHandler().giveItemAsync(player, item) + : plugin.getFullInventoryHandler().giveItemWhenEligibleAsync(player, eligibility, item); + }, descriptor.toString())) { return; } // Preserve ordinary fire-and-forget behavior without adding an ignored async @@ -2077,10 +2270,21 @@ private boolean scheduleOwnedPlayerAction(Player player, Runnable action, String private void scheduleLegacyRewardActionAsync(Supplier> action, Player player, boolean playerAware, String descriptor) { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + Supplier playerEligibility = collection != null && collection.belongsTo(this) + && collection.playerEligibility != null ? collection::isPlayerEligible : null; + if (playerAware && playerEligibility != null && collection.playerResolved + && player != collection.resolvedPlayer) { + collectAsyncFailure(replayActionNotStarted("Player session changed before reward action scheduling", + Reward.playerEligibilityDeferred())); + return; + } + PlayerEligibilityGuard globalEligibility = !playerAware && playerEligibility != null + ? capturePlayerEligibilityGuard() : null; if (!collectAsyncAction(() -> { CompletableFuture completion = new CompletableFuture<>(); AtomicBoolean claimed = new AtomicBoolean(); - Runnable dispatch = () -> { + Runnable finalDispatch = () -> { if (!claimed.compareAndSet(false, true)) return; if (!plugin.isEnabled()) { completion.completeExceptionally(new LegacyActionNotStartedException( @@ -2088,7 +2292,11 @@ private void scheduleLegacyRewardActionAsync(Supplier> act return; } try { - if (playerAware) validateLiveScheduledPlayer(player); + if (playerAware) validateLiveScheduledPlayer(player, playerEligibility != null); + if (playerAware && playerEligibility != null && !playerEligibility.get()) { + throw replayActionNotStarted("Player reward context became unavailable before mutation", + Reward.playerEligibilityDeferred()); + } CompletionStage stage = action.get(); if (stage == null) { completion.completeExceptionally(new IllegalStateException("Scheduled reward action returned null")); @@ -2102,8 +2310,42 @@ private void scheduleLegacyRewardActionAsync(Supplier> act completion.completeExceptionally(failure); } }; + AtomicBoolean started = new AtomicBoolean(); + Runnable dispatch = globalEligibility == null ? finalDispatch : () -> { + if (!started.compareAndSet(false, true) || claimed.get()) return; + try { + globalEligibility.checkAsync().whenComplete((ignored, failure) -> { + if (failure != null) { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + replayActionNotStarted("Player reward context became unavailable before mutation", failure)); + return; + } + try { + // The owner check returns only its result to the global economy callback. + // A Folia player transition can still race this cross-owner handoff. + if (!claimed.get()) getPlugin().getBukkitScheduler().executeOrScheduleSync(plugin, finalDispatch); + } catch (Throwable schedulingFailure) { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + new LegacyActionNotStartedException("Scheduler rejected reward action before dispatch", + schedulingFailure)); + } + }); + } catch (Throwable failure) { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + replayActionNotStarted("Player reward context became unavailable before mutation", failure)); + } + }; try { - if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); + if (playerAware && playerEligibility != null) { + RewardEligibility.runPlayerTask(plugin, player, dispatch, + () -> { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + RewardEligibility.retiredPlayerActionFailure(plugin)); + }, failure -> { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + replayActionNotStarted("Scheduler rejected reward action before dispatch", failure)); + }); + } else if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); else getPlugin().getBukkitScheduler().runTask(plugin, dispatch); } catch (Throwable failure) { claimed.set(true); @@ -2135,12 +2377,22 @@ private void scheduleLegacyRewardActionAsync(Supplier> act /** Runs on the player-owned scheduler before a replay-aware player mutation. */ private void validateLiveScheduledPlayer(Player player) { + validateLiveScheduledPlayer(player, false); + } + + /** A vanished captured session is an eligibility deferral only for an opted-in reward. */ + private void validateLiveScheduledPlayer(Player player, boolean eligibilityDeferral) { + if (eligibilityDeferral && !plugin.isEnabled()) { + throw replayActionNotStarted("Plugin disabled before scheduled reward delivery"); + } if (player == null || player.getUniqueId() == null) { - throw replayActionNotStarted("Scheduled player reward has no live player identity"); + throw replayActionNotStarted("Scheduled player reward has no live player identity", + eligibilityDeferral ? Reward.playerEligibilityDeferred() : null); } Player current = Bukkit.getPlayer(player.getUniqueId()); if (current != player || !current.isOnline()) { - throw replayActionNotStarted("Player became unavailable before scheduled reward delivery"); + throw replayActionNotStarted("Player became unavailable before scheduled reward delivery", + eligibilityDeferral ? Reward.playerEligibilityDeferred() : null); } } @@ -2547,18 +2799,37 @@ public void run() { * replay. It completes only after each queued player command has run. */ public CompletionStage preformCommandAsync(ArrayList commands, HashMap placeholders) { + return preformCommandAsync(commands, placeholders, capturePlayerEligibilityGuard()); + } + + /** Carries the originating reward policy through a mixed console/player continuation. */ + public CompletionStage preformCommandAsync(ArrayList commands, HashMap placeholders, + PlayerEligibilityGuard eligibilityGuard) { + return preformCommandAsync(commands, placeholders, eligibilityGuard, + Reward.currentReplayState(), Reward.currentReplayKey()); + } + + /** Preserves the originating command checkpoint when player dispatch follows an async console sequence. */ + public CompletionStage preformCommandAsync(ArrayList commands, HashMap placeholders, + PlayerEligibilityGuard eligibilityGuard, Reward.ReplayState replayState, String replayKey) { try { ArrayList templates = commands == null ? new ArrayList<>() : new ArrayList<>(commands); - final ArrayList cmds = PlaceholderUtils.replaceJavascript(getPlayer(), + final ArrayList cmds = PlaceholderUtils.replaceJavascript( + eligibilityGuard == null ? getPlayer() : eligibilityGuard.player, PlaceholderUtils.replacePlaceHolder(templates, placeholders)); - return Reward.replayCommandSequence(plugin, placeholders, "player", templates, cmds, (command, ignoredIndex) -> { - final Player player = getPlayer(); + return Reward.replayCommandSequence(plugin, placeholders, "player", templates, cmds, replayState, replayKey, + (command, ignoredIndex) -> { + final Player player = eligibilityGuard == null ? getPlayer() : eligibilityGuard.player; if (player == null || !plugin.isEnabled()) { + if (player == null && eligibilityGuard != null && plugin.isEnabled()) { + return CompletableFuture.failedFuture(replayActionNotStarted( + "Player became unavailable before player command dispatch", Reward.playerEligibilityDeferred())); + } 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); + return runPlayerCommandAsync(player, command, eligibilityGuard); }); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); @@ -2567,19 +2838,26 @@ public CompletionStage preformCommandAsync(ArrayList commands, Has /** Fails before mixed reward sections run console commands for an unavailable player. */ public CompletionStage validatePlayerCommandAvailabilityAsync() { - return getPlayer() == null || !plugin.isEnabled() + PlayerEligibilityGuard guard = capturePlayerEligibilityGuard(); + Player player = guard == null ? getPlayer() : guard.player; + return player == 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) { + private CompletableFuture runPlayerCommandAsync(Player player, String command, + PlayerEligibilityGuard eligibilityGuard) { CompletableFuture completion = new CompletableFuture<>(); AtomicBoolean claimed = new AtomicBoolean(); Runnable dispatch = () -> { if (!claimed.compareAndSet(false, true)) return; try { - validateLiveScheduledPlayer(player); + if (eligibilityGuard == null) validateLiveScheduledPlayer(player); + else { + validateLiveScheduledPlayer(player, true); + eligibilityGuard.checkOnPlayerThread(); + } player.chat("/" + command); completion.complete(null); } catch (Throwable failure) { @@ -2587,10 +2865,19 @@ private CompletableFuture runPlayerCommandAsync(Player player, String comm } }; try { - getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); + if (eligibilityGuard == null) getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); + else RewardEligibility.runPlayerTask(plugin, player, dispatch, + () -> { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + RewardEligibility.retiredPlayerActionFailure(plugin)); + }, failure -> { + if (claimed.compareAndSet(false, true)) completion.completeExceptionally( + replayActionNotStarted("Scheduler rejected player command before dispatch", failure)); + }); } catch (Throwable failure) { claimed.set(true); - completion.completeExceptionally(failure); + completion.completeExceptionally(eligibilityGuard == null ? failure + : replayActionNotStarted("Scheduler rejected player command before dispatch", failure)); return completion; } CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS).execute(() -> { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java new file mode 100644 index 0000000000..45e11c2206 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java @@ -0,0 +1,56 @@ +package com.bencodez.advancedcore.listeners; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerGameModeChangeEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +import com.bencodez.advancedcore.AdvancedCorePlugin; + +/** Wakes existing deferred reward occurrences after player-context transitions. */ +public final class RewardEligibilityListener implements Listener { + private final AdvancedCorePlugin plugin; + public RewardEligibilityListener(AdvancedCorePlugin plugin) { this.plugin = plugin; } + + @EventHandler(priority = EventPriority.LOWEST) + public void onNativeJoin(org.bukkit.event.player.PlayerJoinEvent event) { + var handler = plugin.getRewardHandler(); + if (plugin.isEnabled() && handler != null) { + if (plugin.isLoadUserData()) handler.getRewardEligibility().loginPending(event.getPlayer()); + else handler.getRewardEligibility().nativeSessionReady(event.getPlayer()).whenComplete((ignored, failure) -> { + if (failure != null) plugin.debug("A native reward session could not resume its pending work"); + }); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onGameModeChange(PlayerGameModeChangeEvent event) { + if (!plugin.isEnabled() || event.isCancelled()) return; + var player = event.getPlayer(); + plugin.getBukkitScheduler().runTaskLater(plugin, () -> { + if (plugin.isEnabled() && player.isOnline()) + plugin.getRewardHandler().getRewardEligibility().signal(player); + }, 1, player); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onWorldChange(PlayerChangedWorldEvent event) { + if (plugin.isEnabled() && !plugin.getOptions().isDisableCheckOnWorldChange()) { + plugin.getRewardHandler().getRewardEligibility().signal(event.getPlayer()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onLogin(AdvancedCoreLoginEvent event) { + if (plugin.isEnabled() && !event.isCancelled() && event.getUser() != null && event.getPlayer() != null) { + plugin.getRewardHandler().getRewardEligibility().signalLogin(event.getUser(), event.getPlayer()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + plugin.getRewardHandler().getRewardEligibility().disconnected(event.getPlayer()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/EligibilityAccess.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/EligibilityAccess.java new file mode 100644 index 0000000000..4c1e85c60f --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/EligibilityAccess.java @@ -0,0 +1,92 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.LongSupplier; + +import org.bukkit.entity.Player; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardFileData; +import com.bencodez.advancedcore.api.rewards.RewardEligibility; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.tests.support.ReflectiveAccess; + +/** Test-only access to the existing eligibility internals; no production visibility changes. */ +final class EligibilityAccess { + private EligibilityAccess() { } + + static RewardEligibility create(AdvancedCorePlugin plugin, RewardHandler handler) { + return ReflectiveAccess.construct(RewardEligibility.class, + new Class[] { AdvancedCorePlugin.class, RewardHandler.class }, plugin, handler); + } + + static RewardEligibility create(AdvancedCorePlugin plugin, RewardHandler handler, LongSupplier clock) { + return ReflectiveAccess.construct(RewardEligibility.class, + new Class[] { AdvancedCorePlugin.class, RewardHandler.class, LongSupplier.class }, + plugin, handler, clock); + } + + static void bind(Reward reward, AdvancedCorePlugin plugin) { setPlugin(Reward.class, reward, plugin); } + static void bind(RewardFileData config, AdvancedCorePlugin plugin) { setPlugin(RewardFileData.class, config, plugin); } + + private static void setPlugin(Class owner, Object target, AdvancedCorePlugin plugin) { + try { + var field = owner.getDeclaredField("plugin"); + field.setAccessible(true); + field.set(target, plugin); + } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + } + + static void queued(RewardEligibility eligibility, AdvancedCoreUser user) { + ReflectiveAccess.invoke(RewardEligibility.class, eligibility, "queued", + new Class[] { AdvancedCoreUser.class }, user); + } + + static void deferred(RewardEligibility eligibility, AdvancedCoreUser user) { + ReflectiveAccess.invoke(RewardEligibility.class, eligibility, "deferred", + new Class[] { AdvancedCoreUser.class }, user); + } + + static final class Startup { + private static final Class TYPE = ReflectiveAccess.type( + "com.bencodez.advancedcore.api.rewards.RewardEligibilityStartup"); + static Class type() { return TYPE; } + static void start(AdvancedCorePlugin plugin, RewardEligibility eligibility) { + ReflectiveAccess.invoke(TYPE, null, "start", + new Class[] { AdvancedCorePlugin.class, RewardEligibility.class }, plugin, eligibility); + } + static CompletableFuture authenticate(AdvancedCorePlugin plugin, RewardEligibility eligibility, + Player player, String name, boolean online, AtomicBoolean permit) { + return ReflectiveAccess.invoke(TYPE, null, "authenticate", new Class[] { + AdvancedCorePlugin.class, RewardEligibility.class, Player.class, String.class, + boolean.class, AtomicBoolean.class }, plugin, eligibility, player, name, online, permit); + } + static com.lenis0012.bukkit.loginsecurity.session.PlayerSession loginSecuritySession(Player player) { + return ReflectiveAccess.invoke(TYPE, null, "loginSecuritySession", new Class[] { Player.class }, player); + } + } + + static final class Overflow { + private static final Class TYPE = ReflectiveAccess.type( + "com.bencodez.advancedcore.api.rewards.RewardEligibilityOverflow"); + static final int BATCH = batchSize(); + private static int batchSize() { + try { + var field = TYPE.getDeclaredField("BATCH"); + field.setAccessible(true); + return field.getInt(null); + } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + } + private final Object target; + Overflow(Object target) { this.target = target; } + void enable() { ReflectiveAccess.invoke(TYPE, target, "enable", new Class[0]); } + void sweep() { ReflectiveAccess.invoke(TYPE, target, "sweep", new Class[0]); } + CompletionStage signal(Player player) { + return ReflectiveAccess.invoke(TYPE, target, "signal", new Class[] { Player.class }, player); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RetiringOwnerScheduler.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RetiringOwnerScheduler.java new file mode 100644 index 0000000000..74125dbdd3 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RetiringOwnerScheduler.java @@ -0,0 +1,102 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import com.bencodez.advancedcore.api.rewards.*; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.Consumer; + +import org.bukkit.entity.Entity; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.simpleapi.folialib.FoliaLib; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.simpleapi.folialib.impl.ServerImplementation; +import com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +/** Models the pinned Folia boundary without replacing reward completion or eligibility. */ +final class RetiringOwnerScheduler { + private enum Admission { NORMAL, IMMEDIATE_RETIREMENT, LATER_RETIREMENT, REJECTED } + + private final Consumer ownerQueue; + private final Consumer retirementQueue; + private Admission next = Admission.NORMAL; + private Runnable beforeRetirement = () -> { }; + private Consumer staleOwner; + private Runnable staleRetirement; + private CompletableFuture staleResult; + + RetiringOwnerScheduler(AdvancedCorePlugin plugin, BukkitScheduler scheduler, Consumer ownerQueue, + Consumer retirementQueue) { + this.ownerQueue = ownerQueue; + this.retirementQueue = retirementQueue; + FoliaLib folia = mock(FoliaLib.class); + ServerImplementation implementation = mock(ServerImplementation.class); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.isFolia()).thenReturn(true); + when(folia.getImpl()).thenReturn(implementation); + // Exercise the real wrapper that discards runAtEntity's returned future. + doCallRealMethod().when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class)); + when(implementation.runAtEntity(any(Entity.class), any())).thenAnswer(call -> + admit(call.getArgument(1), null)); + when(implementation.runAtEntityWithFallback(any(Entity.class), any(), any(Runnable.class))).thenAnswer(call -> + admit(call.getArgument(1), call.getArgument(2))); + } + + void retireNext(boolean immediate) { + next = immediate ? Admission.IMMEDIATE_RETIREMENT : Admission.LATER_RETIREMENT; + } + + void rejectNext() { + next = Admission.REJECTED; + } + + void beforeRetirement(Runnable callback) { + beforeRetirement = callback; + } + + void enqueueStaleOwnerCallback() { + if (staleOwner == null) throw new IllegalStateException("No retired owner callback was captured"); + ownerQueue.accept(() -> staleOwner.accept(null)); + } + + void repeatRetirementSignal() { + if (staleRetirement != null) staleRetirement.run(); + if (staleResult != null) staleResult.complete(EntityTaskResult.ENTITY_RETIRED); + } + + private CompletableFuture admit(Consumer action, Runnable retired) { + CompletableFuture result = new CompletableFuture<>(); + Admission admission = next; + next = Admission.NORMAL; + if (admission == Admission.NORMAL) { + ownerQueue.accept(() -> { + action.accept(null); + result.complete(EntityTaskResult.SUCCESS); + }); + } else { + staleOwner = action; + staleRetirement = retired; + staleResult = result; + if (admission == Admission.REJECTED) { + throw new RejectedExecutionException("Synthetic owner scheduler rejection"); + } + if (admission == Admission.IMMEDIATE_RETIREMENT) { + beforeRetirement.run(); + // The pinned API returns this status without calling the fallback. + result.complete(EntityTaskResult.SCHEDULER_RETIRED); + } else { + retirementQueue.accept(() -> { + beforeRetirement.run(); + if (retired != null) retired.run(); + result.complete(EntityTaskResult.ENTITY_RETIRED); + }); + } + } + return result; + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardDefaultGameModesTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardDefaultGameModesTest.java new file mode 100644 index 0000000000..b63eedd380 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardDefaultGameModesTest.java @@ -0,0 +1,123 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import org.bukkit.GameMode; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.RewardEligibility; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.file.YMLConfig; + +class RewardDefaultGameModesTest { + @Test void annotatedOptionsLoadDefaultsAndKeepTheEmptyCompatibilityDefault() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = new AdvancedCoreConfigOptions(); + assertTrue(options.getDefaultRewardGameModes().isEmpty()); + YamlConfiguration data = new YamlConfiguration(); + data.set("DefaultRewardGameModes", List.of("SURVIVAL", "ADVENTURE")); + YMLConfig config = mock(YMLConfig.class); + when(config.getData()).thenReturn(data); + options.setYmlConfig(config); + options.load(plugin); + assertEquals(List.of("SURVIVAL", "ADVENTURE"), options.getDefaultRewardGameModes()); + data.set("DefaultRewardGameModes", null); + options.load(plugin); + assertTrue(options.getDefaultRewardGameModes().isEmpty(), "Removing a default must clear the loaded value"); + } + + @Test void absentOrEmptyDefaultsKeepLegacyBehavior() { + Fixture f = new Fixture(); + assertTrue(f.eligibility.isEligible(f.reward, f.user)); + f.defaults.set("DefaultRewardGameModes", List.of()); + assertTrue(f.eligibility.isEligible(f.reward, f.user)); + verify(f.user, never()).getPlayer(); + } + + @Test void globalAllowlistUsesCurrentNativeMode() { + Fixture f = new Fixture(); + f.defaults.set("DefaultRewardGameModes", List.of("survival")); + assertFalse(f.eligibility.isEligible(f.reward, f.user)); + when(f.player.getGameMode()).thenReturn(GameMode.SURVIVAL); + assertTrue(f.eligibility.isEligible(f.reward, f.user)); + } + + @Test void explicitRewardAllowlistOverridesDefault() { + Fixture f = new Fixture(); + f.defaults.set("DefaultRewardGameModes", List.of("SURVIVAL")); + f.reward.set("GameModes", List.of("CREATIVE")); + assertTrue(f.eligibility.isEligible(f.reward, f.user)); + } + + @Test void explicitEmptyRewardAllowlistOptsOutButDenylistStillWins() { + Fixture f = new Fixture(); + f.defaults.set("DefaultRewardGameModes", List.of("SURVIVAL")); + f.reward.set("GameModes", List.of()); + assertTrue(f.eligibility.isEligible(f.reward, f.user)); + f.reward.set("BlackListedGameModes", List.of("CREATIVE")); + assertFalse(f.eligibility.isEligible(f.reward, f.user)); + } + + @Test void rewardDenylistCombinesWithDefaultAllowlist() { + Fixture f = new Fixture(); + f.defaults.set("DefaultRewardGameModes", List.of("SURVIVAL", "CREATIVE")); + f.reward.set("BlackListedGameModes", List.of("CREATIVE")); + assertFalse(f.eligibility.isEligible(f.reward, f.user)); + when(f.player.getGameMode()).thenReturn(GameMode.SURVIVAL); + assertTrue(f.eligibility.isEligible(f.reward, f.user)); + } + + @Test void invalidGlobalShapeNamesAndMembersFailClosedWithoutNativeReads() { + Fixture f = new Fixture(); + for (Object invalid : List.of("SURVIVAL", List.of("UNKNOWN"), List.of(1), + List.of("SURVIVAL", "CREATIVE", "ADVENTURE", "SPECTATOR", "SURVIVAL"))) { + f.defaults.set("DefaultRewardGameModes", invalid); + assertFalse(f.eligibility.isEligible(f.reward, f.user), "Invalid default: " + invalid); + } + verify(f.user, never()).getPlayer(); + } + + @Test void explicitValidRewardRulesOverrideMalformedDefault() { + Fixture f = new Fixture(); + f.defaults.set("DefaultRewardGameModes", "invalid"); + f.reward.set("GameModes", List.of("CREATIVE")); + assertTrue(f.eligibility.isEligible(f.reward, f.user)); + } + + @Test void changedSavedDefaultsAreObservedWithoutMutatingRewardDefinition() { + Fixture f = new Fixture(); + f.defaults.set("DefaultRewardGameModes", List.of("SURVIVAL")); + assertFalse(f.eligibility.isEligible(f.reward, f.user)); + f.defaults.set("DefaultRewardGameModes", List.of("CREATIVE")); + assertTrue(f.eligibility.isEligible(f.reward, f.user)); + f.defaults.set("DefaultRewardGameModes", null); + assertTrue(f.eligibility.isEligible(f.reward, f.user)); + assertFalse(f.reward.contains("GameModes", true)); + } + + private static final class Fixture { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final AdvancedCoreUser user = mock(AdvancedCoreUser.class); + final Player player = mock(Player.class); + final YamlConfiguration defaults = new YamlConfiguration(), reward = new YamlConfiguration(); + final RewardEligibility eligibility; + Fixture() { + AdvancedCoreConfigOptions options = new AdvancedCoreConfigOptions(); + YMLConfig config = mock(YMLConfig.class); + when(config.getData()).thenReturn(defaults); + options.setYmlConfig(config); + when(plugin.getOptions()).thenReturn(options); + when(user.getPlugin()).thenReturn(plugin); + when(user.getPlayer()).thenReturn(player); + when(player.isOnline()).thenReturn(true); + when(player.getGameMode()).thenReturn(GameMode.CREATIVE); + eligibility = EligibilityAccess.create(plugin, mock(RewardHandler.class)); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardEligibilityOverflowTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardEligibilityOverflowTest.java new file mode 100644 index 0000000000..1716273849 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardEligibilityOverflowTest.java @@ -0,0 +1,249 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import com.bencodez.advancedcore.api.rewards.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.metadata.FixedMetadataValue; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserManager; + +class RewardEligibilityOverflowTest { + @Test void saturatedTransientAdmissionIsRediscoveredAfterCapacityReturns() throws Exception { + try (Fixture fixture = new Fixture()) { + List nodes = new ArrayList<>(); + for (int i = 0; i <= EligibilityAccess.Overflow.BATCH; i++) nodes.add(fixture.node("Synthetic" + i)); + fixture.overflow.enable(); + for (Node node : nodes) fixture.overflow.signal(node.player); + for (int i = 0; i < EligibilityAccess.Overflow.BATCH; i++) + verify(nodes.get(i).user).checkOfflineRewardsAsync(); + verify(nodes.get(EligibilityAccess.Overflow.BATCH).user, never()).checkOfflineRewardsAsync(); + for (int i = 0; i < EligibilityAccess.Overflow.BATCH; i++) nodes.get(i).work.complete(null); + fixture.online = nodes.stream().map(node -> node.player).toList(); + fixture.clock.set(TimeUnit.SECONDS.toNanos(11)); + fixture.overflow.sweep(); + // A changed online collection cannot displace the unvisited member + // of the already captured bounded discovery cycle. + fixture.online = List.of(); + fixture.clock.set(TimeUnit.SECONDS.toNanos(32)); + fixture.overflow.sweep(); + verify(nodes.get(EligibilityAccess.Overflow.BATCH).user).checkOfflineRewardsAsync(); + // No incoming event was needed for the entry rejected at admission. + verify(fixture.users).getUserAsync(eq("Synthetic128"), any(), any()); + } + } + + @Test void repeatedSignalsShareOneLiveResolutionAndReplay() throws Exception { + try (Fixture fixture = new Fixture()) { + Node node = fixture.node("Synthetic"); + fixture.overflow.enable(); + var first = fixture.overflow.signal(node.player); + assertSame(first, fixture.overflow.signal(node.player)); + verify(fixture.users, times(1)).getUserAsync(eq("Synthetic"), any(), any()); + verify(node.user, times(1)).checkOfflineRewardsAsync(); + node.work.complete(null); + assertTrue(first.toCompletableFuture().isDone()); + } + } + + @Test void unauthenticatedNativeSessionCannotResolveOrReadQueues() throws Exception { + try (Fixture fixture = new Fixture()) { + Node node = fixture.node("Synthetic"); + node.accepted = false; + fixture.overflow.enable(); + fixture.overflow.signal(node.player).toCompletableFuture().join(); + verifyNoInteractions(fixture.users); + verify(node.user, never()).checkOfflineRewardsAsync(); + } + } + + @Test void quitRetainsClaimGateUntilWorkerFinishesAndReplacementNeedsReceipt() throws Exception { + try (Fixture fixture = new Fixture()) { + Node node = fixture.node("Synthetic"); + fixture.overflow.enable(); + var result = fixture.overflow.signal(node.player); + fixture.onOwner(() -> fixture.eligibility.disconnected(node.player)); + node.accepted = false; + fixture.onOwner(() -> assertFalse(fixture.eligibility.isEligible(fixture.policy, node.user))); + node.work.complete(null); + result.toCompletableFuture().join(); + // Even after the temporary admission retires, the replacement may not + // become eligible merely because its gamemode matches. + fixture.onOwner(() -> assertFalse(fixture.eligibility.isEligible(fixture.policy, node.user))); + fixture.onOwner(() -> fixture.eligibility.signalLogin(node.user, node.player)); + node.accepted = true; + fixture.onOwner(() -> assertTrue(fixture.eligibility.isEligible(fixture.policy, node.user))); + } + } + + @Test void lateUserResolutionCannotRetargetAReplacementNativeSession() throws Exception { + try (Fixture fixture = new Fixture()) { + Node node = fixture.node("Synthetic"); + var resolution = new java.util.concurrent.atomic.AtomicReference>(); + doAnswer(call -> { resolution.set(call.getArgument(1)); return null; }) + .when(fixture.users).getUserAsync(eq("Synthetic"), any(), any()); + fixture.overflow.enable(); + var result = fixture.overflow.signal(node.player); + assertNotNull(resolution.get()); + when(node.user.getPlayer()).thenReturn(mock(Player.class)); + resolution.get().accept(node.user); + result.toCompletableFuture().join(); + verify(node.user, never()).checkOfflineRewardsAsync(); + verify(node.user, never()).checkDelayedTimedRewards(); + } + } + + @Test void closeCompletesLiveDiscoveryAndLateCompletionCannotStartAnotherReplay() throws Exception { + try (Fixture fixture = new Fixture()) { + Node node = fixture.node("Synthetic"); + fixture.overflow.enable(); + var result = fixture.overflow.signal(node.player); + fixture.eligibility.close(); + assertTrue(result.toCompletableFuture().isCompletedExceptionally()); + node.work.complete(null); + fixture.overflow.signal(node.player).toCompletableFuture().join(); + verify(node.user, times(1)).checkOfflineRewardsAsync(); + } + } + + @Test void disconnectedAdmissionsRetireAfterTheirOutstandingClaimsFinish() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.overflow.enable(); + List nodes = new ArrayList<>(); + for (int i = 0; i < EligibilityAccess.Overflow.BATCH; i++) { + Node node = fixture.node("Retained" + i); + nodes.add(node); + node.claimed.set(true); + node.work.complete(null); + fixture.overflow.signal(node.player).toCompletableFuture().join(); + fixture.onOwner(() -> fixture.eligibility.disconnected(node.player)); + node.accepted = false; + node.claimed.set(false); + } + Node next = fixture.node("NextOnline"); + fixture.online = List.of(next.player); + fixture.clock.set(TimeUnit.SECONDS.toNanos(11)); + assertNotNull(fixture.fallbackSweep.get()); + fixture.fallbackSweep.get().run(); + verify(next.user).checkOfflineRewardsAsync(); + for (Node node : nodes) verify(node.user, times(1)).checkOfflineRewardsAsync(); + } + } + + @Test void onePendingReplayDoesNotBlockTheRemainingDiscoverySlots() throws Exception { + try (Fixture fixture = new Fixture()) { + Node slow = fixture.node("SlowOnline"); + Node second = fixture.node("SecondOnline"); + Node third = fixture.node("ThirdOnline"); + second.work.complete(null); + third.work.complete(null); + fixture.online = List.of(slow.player, second.player, third.player); + fixture.overflow.enable(); + fixture.clock.set(TimeUnit.SECONDS.toNanos(11)); + fixture.overflow.sweep(); + assertFalse(slow.work.isDone()); + verify(second.user).checkOfflineRewardsAsync(); + verify(third.user).checkOfflineRewardsAsync(); + } + } + + private static final class Node { + final Player player = mock(Player.class); + final AdvancedCoreUser user = mock(AdvancedCoreUser.class); + final CompletableFuture work = new CompletableFuture<>(); + boolean accepted = true; + final java.util.concurrent.atomic.AtomicBoolean claimed = new java.util.concurrent.atomic.AtomicBoolean(); + } + + private static final class Fixture implements AutoCloseable { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final RewardHandler handler = mock(RewardHandler.class); + final UserManager users = mock(UserManager.class); + final ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + final ScheduledExecutorService delayed = mock(ScheduledExecutorService.class); + final com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + final ThreadLocal owner = ThreadLocal.withInitial(() -> false); + final AtomicLong clock = new AtomicLong(); + final java.util.concurrent.atomic.AtomicReference fallbackSweep = new java.util.concurrent.atomic.AtomicReference<>(); + final HashMap named = new HashMap<>(); + final org.mockito.MockedStatic bukkit = mockStatic(Bukkit.class); + final RewardEligibility eligibility; + final EligibilityAccess.Overflow overflow; + final YamlConfiguration policy = new YamlConfiguration(); + List online = List.of(); + + Fixture() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getRewardHandler()).thenReturn(handler); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getTimer()).thenReturn(timer); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(handler.getDelayedTimer()).thenReturn(delayed); + when(delayed.scheduleWithFixedDelay(any(), anyLong(), anyLong(), any())) + .thenAnswer(call -> { fallbackSweep.set(call.getArgument(0)); return mock(ScheduledFuture.class); }); + doAnswer(call -> { + boolean previous = owner.get(); owner.set(false); + try { call.getArgument(0, Runnable.class).run(); } finally { owner.set(previous); } + return null; + }).when(timer).execute(any()); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { onOwner(call.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + doAnswer(call -> { + assertFalse(owner.get(), "user resolution must not run on the native owner"); + Node node = named.get(call.getArgument(0)); + call.getArgument(1, Consumer.class).accept(node.user); + return null; + }).when(users).getUserAsync(anyString(), any(), any()); + bukkit.when(Bukkit::getOnlinePlayers).thenAnswer(call -> online); + eligibility = EligibilityAccess.create(plugin, handler, clock::get); + when(handler.getRewardEligibility()).thenReturn(eligibility); + var field = RewardEligibility.class.getDeclaredField("overflow"); + field.setAccessible(true); + overflow = new EligibilityAccess.Overflow(field.get(eligibility)); + policy.set("GameModes", List.of("SURVIVAL")); + } + + Node node(String name) { + Node node = new Node(); named.put(name, node); + when(node.player.getName()).thenAnswer(call -> { assertTrue(owner.get()); return name; }); + when(node.player.isOnline()).thenAnswer(call -> { assertTrue(owner.get()); return true; }); + when(node.player.getGameMode()).thenAnswer(call -> { assertTrue(owner.get()); return org.bukkit.GameMode.SURVIVAL; }); + when(node.player.getMetadata(anyString())).thenAnswer(call -> { + assertTrue(owner.get()); + return List.of(new FixedMetadataValue(plugin, node.accepted)); + }); + when(node.user.getPlayer()).thenReturn(node.player); + when(node.user.getPlugin()).thenReturn(plugin); + when(node.user.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(node.user.hasPendingRewardReplay()).thenAnswer(call -> node.claimed.get()); + when(node.user.checkOfflineRewardsAsync()).thenAnswer(call -> { assertFalse(owner.get()); return node.work; }); + return node; + } + void onOwner(Runnable action) { + boolean previous = owner.get(); owner.set(true); + try { action.run(); } finally { owner.set(previous); } + } + @Override public void close() { eligibility.close(); bukkit.close(); } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardEligibilityStartupTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardEligibilityStartupTest.java new file mode 100644 index 0000000000..cc7197f694 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardEligibilityStartupTest.java @@ -0,0 +1,365 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import com.bencodez.advancedcore.api.rewards.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Consumer; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.event.player.PlayerJoinEvent; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent; +import com.bencodez.advancedcore.listeners.RewardEligibilityListener; + +class RewardEligibilityStartupTest { + @Test void existingOnlineSessionWithoutAnAuthenticationPluginGetsAReceipt() { + try (Fixture fixture = new Fixture()) { + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertEquals(1, fixture.receipts.size(), "an existing native session must not wait for a new login"); + assertSame(fixture.plugin, fixture.receipts.get(0).getOwningPlugin()); + assertEquals(Boolean.TRUE, fixture.receipts.get(0).value()); + verify(fixture.users).getUserAsync(eq("StartupPlayer"), any(), any()); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + verify(fixture.user, never()).checkDelayedTimedRewards(); + } + } + + @Test void noDataNativeJoinDoesNotLeaveTheSessionPending() { + try (Fixture fixture = new Fixture()) { + when(fixture.plugin.isLoadUserData()).thenReturn(false); + fixture.onOwner(() -> new RewardEligibilityListener(fixture.plugin) + .onNativeJoin(new PlayerJoinEvent(fixture.player, ""))); + assertEquals(Boolean.TRUE, fixture.receipts.get(0).value(), "native join alone accepts the unmanaged session"); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertEquals(Boolean.TRUE, fixture.receipts.get(0).value()); + verifyNoInteractions(fixture.users); + } + } + + @Test void noDataStartupAcceptsExistingSessionWithoutLoadingUserOrAuthenticationProvider() { + try (Fixture fixture = new Fixture()) { + when(fixture.plugin.isLoadUserData()).thenReturn(false); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertEquals(Boolean.TRUE, fixture.receipts.get(0).value()); + verifyNoInteractions(fixture.users); + verify(fixture.plugin, never()).isAuthMeLoaded(); + verify(fixture.plugin, never()).isNLoginLoaded(); + verify(fixture.plugin, never()).isLoginSecurityLoaded(); + } + } + + @Test void enabledNativeJoinRemainsPendingUntilTheOrdinaryLoginPathAcceptsIt() { + try (Fixture fixture = new Fixture()) { + when(fixture.plugin.isLoadUserData()).thenReturn(true); + fixture.onOwner(() -> new RewardEligibilityListener(fixture.plugin) + .onNativeJoin(new PlayerJoinEvent(fixture.player, ""))); + assertEquals(Boolean.FALSE, fixture.receipts.get(0).value()); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertEquals(Boolean.FALSE, fixture.receipts.get(0).value()); + verifyNoInteractions(fixture.users); + new RewardEligibilityListener(fixture.plugin).onLogin( + new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), fixture.player)); + assertEquals(Boolean.TRUE, fixture.receipts.get(0).value()); + } + } + + @Test void noDataStartupRebindsOnlyKnownWorkForTheExactNativeSession() { + try (Fixture fixture = new Fixture()) { + when(fixture.plugin.isLoadUserData()).thenReturn(false); + when(fixture.user.checkOfflineRewardsAsync()).thenReturn(CompletableFuture.completedFuture(null)); + AdvancedCoreUser unrelated = mock(AdvancedCoreUser.class); + when(unrelated.getPlugin()).thenReturn(fixture.plugin); + when(unrelated.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(unrelated.getPlayer()).thenReturn(mock(Player.class)); + EligibilityAccess.queued(fixture.eligibility, fixture.user); + fixture.eligibility.signal(fixture.user).toCompletableFuture().join(); + EligibilityAccess.queued(fixture.eligibility, unrelated); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertEquals(Boolean.TRUE, fixture.receipts.get(0).value()); + verify(fixture.user).checkDelayedTimedRewards(); + verify(fixture.user).checkOfflineRewardsAsync(); + verify(unrelated, never()).checkDelayedTimedRewards(); + verify(unrelated, never()).checkOfflineRewardsAsync(); + verifyNoInteractions(fixture.users); + } + } + + @Test void enabledUserDataCannotUseTheNativeSessionShortcut() { + try (Fixture fixture = new Fixture()) { + fixture.onOwner(() -> { + fixture.eligibility.loginPending(fixture.player); + fixture.eligibility.nativeSessionReady(fixture.player).toCompletableFuture().join(); + }); + assertEquals(Boolean.FALSE, fixture.receipts.get(0).value()); + verifyNoInteractions(fixture.users); + } + } + + @Test void startupRebindsAnAlreadyAwaitingUnknownHintAndWakesItsRealQueue() { + try (Fixture fixture = new Fixture()) { + when(fixture.user.checkOfflineRewardsAsync()).thenReturn(CompletableFuture.completedFuture(null)); + EligibilityAccess.queued(fixture.eligibility, fixture.user); + fixture.eligibility.signal(fixture.user).toCompletableFuture().join(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertEquals(Boolean.TRUE, fixture.receipts.get(0).value()); + verify(fixture.user).checkDelayedTimedRewards(); + verify(fixture.user).checkOfflineRewardsAsync(); + } + } + + @Test void waitingWithoutALoadedProviderStillBootstrapsExistingPlayers() { + try (Fixture fixture = new Fixture()) { + when(fixture.options.isWaitUntilLoggedIn()).thenReturn(true); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertEquals(Boolean.TRUE, fixture.receipts.get(0).value()); + } + } + + @Test void authMeDenialDoesNotStampOrLoadAUser() { + try (Fixture fixture = new Fixture(); var api = mockStatic(fr.xephi.authme.api.v3.AuthMeApi.class)) { + when(fixture.options.isWaitUntilLoggedIn()).thenReturn(true); + when(fixture.plugin.isAuthMeLoaded()).thenReturn(true); + var provider = mock(fr.xephi.authme.api.v3.AuthMeApi.class); + api.when(fr.xephi.authme.api.v3.AuthMeApi::getInstance).thenReturn(provider); + when(provider.isAuthenticated(fixture.player)).thenAnswer(call -> { + assertTrue(fixture.owner.get(), "AuthMe accesses the native name only on its owner"); return false; + }); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertTrue(fixture.receipts.isEmpty()); + verifyNoInteractions(fixture.users); + } + } + + @Test void authenticatedAuthMeSessionUsesTheExistingLoginReceiptPath() { + try (Fixture fixture = new Fixture(); var api = mockStatic(fr.xephi.authme.api.v3.AuthMeApi.class)) { + when(fixture.options.isWaitUntilLoggedIn()).thenReturn(true); + when(fixture.plugin.isAuthMeLoaded()).thenReturn(true); + var provider = mock(fr.xephi.authme.api.v3.AuthMeApi.class); + api.when(fr.xephi.authme.api.v3.AuthMeApi::getInstance).thenReturn(provider); + when(provider.isAuthenticated(fixture.player)).thenReturn(true); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertEquals(Boolean.TRUE, fixture.receipts.get(0).value()); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + } + } + + @Test void unavailableNLoginFailsClosedOnItsWorker() { + try (Fixture fixture = new Fixture(); var api = mockStatic(com.nickuc.login.api.nLoginAPI.class)) { + when(fixture.options.isWaitUntilLoggedIn()).thenReturn(true); + when(fixture.plugin.isNLoginLoaded()).thenReturn(true); + var provider = mock(com.nickuc.login.api.nLoginAPI.class); + api.when(com.nickuc.login.api.nLoginAPI::getApi).thenReturn(provider); + when(provider.isAvailable()).thenAnswer(call -> { assertFalse(fixture.owner.get()); return false; }); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertTrue(fixture.receipts.isEmpty()); + verifyNoInteractions(fixture.users); + verify(provider, never()).isAuthenticated(anyString()); + } + } + + @Test void loginSecurityChecksRunOffTheGameplayOwnerAndDenialIsNotAccepted() { + try (Fixture fixture = new Fixture(); var api = mockStatic(EligibilityAccess.Startup.type(), CALLS_REAL_METHODS)) { + when(fixture.options.isWaitUntilLoggedIn()).thenReturn(true); + when(fixture.plugin.isLoginSecurityLoaded()).thenReturn(true); + var session = mock(com.lenis0012.bukkit.loginsecurity.session.PlayerSession.class); + // Isolate the provider's lookup; the real startup, scheduling, + // authorization predicate and receipt publication still execute. + api.when(() -> EligibilityAccess.Startup.loginSecuritySession(fixture.player)) + .thenAnswer(call -> { assertFalse(fixture.owner.get()); return session; }); + when(session.isLoggedIn()).thenReturn(false); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertTrue(fixture.receipts.isEmpty()); + verifyNoInteractions(fixture.users); + } + } + + @Test void loginSecurityAuthorizedSessionDoesNotRequireAnAdditionalPasswordCheck() { + try (Fixture fixture = new Fixture(); var api = mockStatic(EligibilityAccess.Startup.type(), CALLS_REAL_METHODS)) { + when(fixture.options.isWaitUntilLoggedIn()).thenReturn(true); + when(fixture.plugin.isLoginSecurityLoaded()).thenReturn(true); + var session = mock(com.lenis0012.bukkit.loginsecurity.session.PlayerSession.class); + api.when(() -> EligibilityAccess.Startup.loginSecuritySession(fixture.player)).thenReturn(session); + // The pinned API's isLoggedIn additionally requires a password, + // while isAuthorized is its authoritative AUTHENTICATED state. + when(session.isAuthorized()).thenReturn(true); + when(session.isLoggedIn()).thenReturn(false); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertEquals(Boolean.TRUE, fixture.receipts.get(0).value()); + verify(session, never()).isLoggedIn(); + } + } + + @Test void nativeJoinPendingReceiptCannotBeBypassedByTheStartupSnapshot() { + try (Fixture fixture = new Fixture()) { + fixture.onOwner(() -> fixture.eligibility.loginPending(fixture.player)); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertEquals(Boolean.FALSE, fixture.receipts.get(0).value()); + verifyNoInteractions(fixture.users); + } + } + + @Test void changedHandlerCannotPublishTheDelayedSessionReceipt() { + try (Fixture fixture = new Fixture()) { + var resolution = new java.util.concurrent.atomic.AtomicReference>(); + doAnswer(call -> { resolution.set(call.getArgument(1)); return null; }) + .when(fixture.users).getUserAsync(anyString(), any(), any()); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertNotNull(resolution.get()); + when(fixture.plugin.getRewardHandler()).thenReturn(mock(RewardHandler.class)); + resolution.get().accept(fixture.user); + assertTrue(fixture.receipts.isEmpty()); + } + } + + @Test void disconnectedSessionCannotPublishTheDelayedSessionReceipt() { + try (Fixture fixture = new Fixture()) { + var resolution = new java.util.concurrent.atomic.AtomicReference>(); + doAnswer(call -> { resolution.set(call.getArgument(1)); return null; }) + .when(fixture.users).getUserAsync(anyString(), any(), any()); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertNotNull(resolution.get()); + doAnswer(call -> { assertTrue(fixture.owner.get()); return false; }).when(fixture.player).isOnline(); + resolution.get().accept(fixture.user); + assertTrue(fixture.receipts.isEmpty()); + } + } + + @Test void closedEligibilityCannotBootstrapANativeSession() { + try (Fixture fixture = new Fixture()) { + fixture.eligibility.close(); + EligibilityAccess.Startup.start(fixture.plugin, fixture.eligibility); + assertTrue(fixture.receipts.isEmpty()); + verifyNoInteractions(fixture.users); + } + } + + @Test void timedOutProviderKeepsItsPhysicalPermitUntilItActuallyReturns() { + try (Fixture fixture = new Fixture(); var api = mockStatic(com.nickuc.login.api.nLoginAPI.class)) { + var queued = new ArrayList(); + doAnswer(call -> { queued.add(call.getArgument(0)); return null; }).when(fixture.timer).execute(any()); + when(fixture.plugin.isNLoginLoaded()).thenReturn(true); + var provider = mock(com.nickuc.login.api.nLoginAPI.class); + api.when(com.nickuc.login.api.nLoginAPI::getApi).thenReturn(provider); + when(provider.isAvailable()).thenReturn(true); + var inFlight = new java.util.concurrent.atomic.AtomicBoolean(); + var first = EligibilityAccess.Startup.authenticate(fixture.plugin, fixture.eligibility, + fixture.player, "StartupPlayer", true, inFlight); + when(provider.isAuthenticated("StartupPlayer")).thenAnswer(call -> { + // Same result transition as orTimeout while the provider is physically running. + first.completeExceptionally(new java.util.concurrent.TimeoutException()); + var second = EligibilityAccess.Startup.authenticate(fixture.plugin, fixture.eligibility, + fixture.player, "AnotherPlayer", true, inFlight); + assertTrue(second.isCompletedExceptionally()); + assertEquals(1, queued.size(), "a logical timeout must not enqueue another provider query"); + return true; + }); + queued.get(0).run(); + assertTrue(first.isCompletedExceptionally(), "late provider acceptance cannot replace timeout"); + assertFalse(inFlight.get(), "physical return releases the query slot"); + verify(provider, never()).isAuthenticated("AnotherPlayer"); + verifyNoInteractions(fixture.users); + assertTrue(fixture.receipts.isEmpty()); + } + } + + @Test void successfulInlineContinuationCanAcquireTheNextQueryPermit() { + try (Fixture fixture = new Fixture()) { + var queued = new ArrayList(); + doAnswer(call -> { queued.add(call.getArgument(0)); return null; }).when(fixture.timer).execute(any()); + var inFlight = new java.util.concurrent.atomic.AtomicBoolean(); + var first = EligibilityAccess.Startup.authenticate(fixture.plugin, fixture.eligibility, + fixture.player, "StartupPlayer", false, inFlight); + var next = first.thenCompose(accepted -> EligibilityAccess.Startup.authenticate(fixture.plugin, + fixture.eligibility, fixture.player, "AnotherPlayer", false, inFlight)); + queued.get(0).run(); + assertEquals(2, queued.size()); + queued.get(1).run(); + assertTrue(next.join()); + assertFalse(inFlight.get()); + } + } + + @Test void rejectedProviderSubmissionReleasesItsPermitForRecovery() { + try (Fixture fixture = new Fixture()) { + var inFlight = new java.util.concurrent.atomic.AtomicBoolean(); + doThrow(new java.util.concurrent.RejectedExecutionException()).when(fixture.timer).execute(any()); + var result = EligibilityAccess.Startup.authenticate(fixture.plugin, fixture.eligibility, + fixture.player, "StartupPlayer", false, inFlight); + assertTrue(result.isCompletedExceptionally()); + assertFalse(inFlight.get()); + verifyNoInteractions(fixture.users); + assertTrue(fixture.receipts.isEmpty()); + } + } + + private static final class Fixture implements AutoCloseable { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final RewardHandler handler = mock(RewardHandler.class); + final AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + final UserManager users = mock(UserManager.class); + final AdvancedCoreUser user = mock(AdvancedCoreUser.class); + final Player player = mock(Player.class); + final List receipts = new ArrayList<>(); + final ThreadLocal owner = ThreadLocal.withInitial(() -> false); + final org.mockito.MockedStatic bukkit = mockStatic(Bukkit.class); + final com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + final ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + final RewardEligibility eligibility; + + Fixture() { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getRewardHandler()).thenReturn(handler); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { + boolean previous = owner.get(); owner.set(false); + try { call.getArgument(0, Runnable.class).run(); } finally { owner.set(previous); } + return null; + }).when(timer).execute(any()); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { onOwner(call.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + when(player.getName()).thenReturn("StartupPlayer"); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.isOnline()).thenAnswer(call -> { assertTrue(owner.get(), "native online state requires its owner"); return true; }); + when(player.getMetadata(anyString())).thenAnswer(call -> { assertTrue(owner.get()); return new ArrayList<>(receipts); }); + doAnswer(call -> { assertTrue(owner.get()); receipts.clear(); receipts.add(call.getArgument(1)); return null; }) + .when(player).setMetadata(anyString(), any(MetadataValue.class)); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(user.getPlayer()).thenReturn(player); + doAnswer(call -> { call.getArgument(1, Consumer.class).accept(user); return null; }) + .when(users).getUserAsync(anyString(), any(), any()); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + eligibility = EligibilityAccess.create(plugin, handler); + when(handler.getRewardEligibility()).thenReturn(eligibility); + } + void onOwner(Runnable callback) { + boolean previous = owner.get(); owner.set(true); + try { callback.run(); } finally { owner.set(previous); } + } + @Override public void close() { eligibility.close(); bukkit.close(); } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardEligibilityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardEligibilityTest.java new file mode 100644 index 0000000000..17d5fef700 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardEligibilityTest.java @@ -0,0 +1,552 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import com.bencodez.advancedcore.api.rewards.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import org.bukkit.GameMode; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; + +class RewardEligibilityTest { + @Test + void allowlistAndDenylistUseTheOnlinePlayerMode() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + Player player = mock(Player.class); + when(user.getPlugin()).thenReturn(plugin); + when(user.getPlayer()).thenReturn(player); + when(player.isOnline()).thenReturn(true); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + RewardEligibility eligibility = EligibilityAccess.create(plugin, mock(RewardHandler.class)); + YamlConfiguration data = new YamlConfiguration(); + data.set("GameModes", List.of("SURVIVAL")); + assertTrue(eligibility.isEligible(data, user)); + data.set("BlackListedGameModes", List.of("survival")); + assertFalse(eligibility.isEligible(data, user)); + } + + @Test + void missingConfigurationAndMalformedValuesAreHandledSafely() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + RewardEligibility eligibility = EligibilityAccess.create(plugin, mock(RewardHandler.class)); + YamlConfiguration data = new YamlConfiguration(); + assertTrue(eligibility.isEligible(data, user)); + data.set("GameModes", List.of("UNKNOWN")); + assertFalse(eligibility.isEligible(data, user)); + data.set("GameModes", "SURVIVAL"); + assertFalse(eligibility.isEligible(data, user)); + verify(user, never()).getPlayer(); + } + + @Test + void ownerSignalDoesNotWaitForStorageOwnershipMonitor() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + RewardHandler handler = mock(RewardHandler.class); + var storage = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); + var owner = java.util.concurrent.Executors.newSingleThreadExecutor(); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getTimer()).thenReturn(storage); + when(handler.getDelayedTimer()).thenReturn(storage); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn("synthetic-user"); + Player player = mock(Player.class); + when(user.getPlayer()).thenReturn(player); + when(player.isOnline()).thenReturn(true); + when(user.hasPendingRewardReplay()).thenAnswer(call -> { + synchronized (plugin) { return false; } + }); + when(user.checkOfflineRewardsAsync()).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + org.bukkit.metadata.MetadataValue accepted = mock(org.bukkit.metadata.MetadataValue.class); + when(accepted.getOwningPlugin()).thenReturn(plugin); + when(accepted.value()).thenReturn(Boolean.TRUE); + when(player.getMetadata(anyString())).thenReturn(List.of(accepted)); + RewardEligibility eligibility = EligibilityAccess.create(plugin, handler); + EligibilityAccess.deferred(eligibility, user); + try { + java.util.concurrent.CompletionStage completion; + synchronized (plugin) { + completion = owner.submit(() -> eligibility.signal(user)).get(1, java.util.concurrent.TimeUnit.SECONDS); + assertFalse(completion.toCompletableFuture().isDone(), + "Ownership observation must wait on the worker while the player owner returns"); + } + completion.toCompletableFuture().get(2, java.util.concurrent.TimeUnit.SECONDS); + } finally { + eligibility.close(); + owner.shutdownNow(); + storage.shutdownNow(); + } + } + + @Test + void rejectedWorkerCompletesSignalWithoutTouchingStorageOnCaller() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + RewardHandler handler = mock(RewardHandler.class); + var worker = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getTimer()).thenReturn(worker); + when(handler.getDelayedTimer()).thenReturn(worker); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn("synthetic-rejected-user"); + doThrow(new java.util.concurrent.RejectedExecutionException()).when(worker).execute(any()); + RewardEligibility eligibility = EligibilityAccess.create(plugin, handler); + try { + assertTrue(eligibility.signal(user).toCompletableFuture().isCompletedExceptionally()); + verify(user, never()).hasPendingRewardReplay(); + verify(user, never()).checkOfflineRewardsAsync(); + } finally { eligibility.close(); } + } + + @Test + void closedSignalCannotResumeItsDiscardedWorkerCallback() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + RewardHandler handler = mock(RewardHandler.class); + var worker = mock(java.util.concurrent.ScheduledExecutorService.class); + var queued = new java.util.concurrent.atomic.AtomicReference(); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getTimer()).thenReturn(worker); + when(handler.getDelayedTimer()).thenReturn(worker); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn("synthetic-closed-user"); + doAnswer(call -> { queued.set(call.getArgument(0)); return null; }).when(worker).execute(any()); + RewardEligibility eligibility = EligibilityAccess.create(plugin, handler); + var completion = eligibility.signal(user).toCompletableFuture(); + assertFalse(completion.isDone()); + eligibility.close(); + assertTrue(completion.isCompletedExceptionally()); + queued.get().run(); + verify(user, never()).hasPendingRewardReplay(); + verify(user, never()).checkOfflineRewardsAsync(); + } + @Test + void fallbackDoesNotReplayBeforeItsFirstDeadline() { + try (SignalFixture fixture = new SignalFixture()) { + EligibilityAccess.queued(fixture.eligibility, fixture.user); + fixture.sweep.get().run(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + } + } + + @Test + void explicitSignalChecksDueTimedRewardsAlongsideOfflineReplay() { + try (SignalFixture fixture = new SignalFixture()) { + fixture.eligibility.signal(fixture.user).toCompletableFuture().join(); + verify(fixture.user).checkDelayedTimedRewards(); + verify(fixture.user).checkOfflineRewardsAsync(); + } + } + + @Test + void fallbackBacksOffToFiveMinutesDespiteRepeatedDeferralHints() { + try (SignalFixture fixture = new SignalFixture()) { + EligibilityAccess.queued(fixture.eligibility, fixture.user); + long due = 10; + long delay = 10; + for (int attempts = 1; attempts <= 9; attempts++) { + fixture.clock.set(java.util.concurrent.TimeUnit.SECONDS.toNanos(due) - 1); + fixture.sweep.get().run(); + verify(fixture.user, times(attempts - 1)).checkOfflineRewardsAsync(); + fixture.clock.incrementAndGet(); + fixture.sweep.get().run(); + verify(fixture.user, times(attempts)).checkOfflineRewardsAsync(); + delay = Math.min(300, delay * 2); + due += delay; + } + } + } + + @Test + void explicitSignalBypassesAndResetsBackoffAcrossMonotonicWrap() { + try (SignalFixture fixture = new SignalFixture()) { + fixture.clock.set(Long.MAX_VALUE - java.util.concurrent.TimeUnit.SECONDS.toNanos(5)); + EligibilityAccess.queued(fixture.eligibility, fixture.user); + fixture.sweep.get().run(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + fixture.clock.addAndGet(java.util.concurrent.TimeUnit.SECONDS.toNanos(10)); + fixture.sweep.get().run(); + verify(fixture.user).checkOfflineRewardsAsync(); + fixture.eligibility.signal(fixture.user).toCompletableFuture().join(); + verify(fixture.user, times(2)).checkOfflineRewardsAsync(); + fixture.clock.addAndGet(java.util.concurrent.TimeUnit.SECONDS.toNanos(10) - 1); + fixture.sweep.get().run(); + verify(fixture.user, times(2)).checkOfflineRewardsAsync(); + fixture.clock.incrementAndGet(); + fixture.sweep.get().run(); + verify(fixture.user, times(3)).checkOfflineRewardsAsync(); + } + } + + @Test + void boundedSweepRotatesPastEntriesWhoseNextAttemptIsNotDue() { + try (SignalFixture fixture = new SignalFixture()) { + java.util.List users = new java.util.ArrayList<>(); + for (int i = 0; i < 129; i++) { + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn("synthetic-rotation-" + i); + // All entries belong to the already accepted live fixture session. + when(user.getPlayer()).thenReturn(fixture.player); + when(user.checkOfflineRewardsAsync()).thenAnswer(call -> { + EligibilityAccess.queued(fixture.eligibility, user); + return java.util.concurrent.CompletableFuture.completedFuture(null); + }); + users.add(user); + EligibilityAccess.queued(fixture.eligibility, user); + } + fixture.clock.set(java.util.concurrent.TimeUnit.SECONDS.toNanos(10)); + fixture.sweep.get().run(); + verify(users.get(128), never()).checkOfflineRewardsAsync(); + fixture.sweep.get().run(); + fixture.sweep.get().run(); + for (AdvancedCoreUser user : users) verify(user).checkOfflineRewardsAsync(); + } + } + + @Test + void explicitSignalCoalescesRunningReplayAndResetsItsNextFallback() { + try (SignalFixture fixture = new SignalFixture()) { + var replay = new java.util.concurrent.CompletableFuture(); + doReturn(replay).when(fixture.user).checkOfflineRewardsAsync(); + EligibilityAccess.queued(fixture.eligibility, fixture.user); + fixture.clock.set(java.util.concurrent.TimeUnit.SECONDS.toNanos(10)); + fixture.sweep.get().run(); + verify(fixture.user).checkOfflineRewardsAsync(); + fixture.clock.addAndGet(java.util.concurrent.TimeUnit.SECONDS.toNanos(1)); + var signal = fixture.eligibility.signal(fixture.user).toCompletableFuture(); + assertFalse(signal.isDone()); + fixture.sweep.get().run(); + verify(fixture.user).checkOfflineRewardsAsync(); + EligibilityAccess.queued(fixture.eligibility, fixture.user); + replay.complete(null); + signal.join(); + fixture.clock.addAndGet(java.util.concurrent.TimeUnit.SECONDS.toNanos(10) - 1); + fixture.sweep.get().run(); + verify(fixture.user).checkOfflineRewardsAsync(); + fixture.clock.incrementAndGet(); + fixture.sweep.get().run(); + verify(fixture.user, times(2)).checkOfflineRewardsAsync(); + fixture.eligibility.close(); + fixture.sweep.get().run(); + verify(fixture.user, times(2)).checkOfflineRewardsAsync(); + } + } + + @Test + void timedRetryHintsRejectForeignClosedAndUnavailableSchedulers() { + try (SignalFixture fixture = new SignalFixture()) { + AdvancedCoreUser foreign = mock(AdvancedCoreUser.class); + when(foreign.getPlugin()).thenReturn(mock(AdvancedCorePlugin.class)); + assertThrows(IllegalArgumentException.class, () -> fixture.eligibility.retainTimedRetry(foreign)); + doThrow(new java.util.concurrent.RejectedExecutionException()).when(fixture.worker) + .scheduleWithFixedDelay(any(Runnable.class), anyLong(), anyLong(), + any(java.util.concurrent.TimeUnit.class)); + assertFalse(fixture.eligibility.retainTimedRetry(fixture.user)); + fixture.eligibility.close(); + assertFalse(fixture.eligibility.retainTimedRetry(fixture.user)); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + verify(fixture.user, never()).checkDelayedTimedRewards(); + } + } + + @Test + void timedRetryHintCapacityPreservesAlreadyAdmittedUsers() { + try (SignalFixture fixture = new SignalFixture()) { + assertTrue(fixture.eligibility.retainTimedRetry(fixture.user)); + for (int i = 1; i < RewardEligibility.MAX_PENDING_PLAYERS; i++) { + AdvancedCoreUser user = mock(AdvancedCoreUser.class, withSettings().stubOnly()); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn("synthetic-capacity-" + i); + assertTrue(fixture.eligibility.retainTimedRetry(user)); + } + AdvancedCoreUser excess = mock(AdvancedCoreUser.class); + when(excess.getPlugin()).thenReturn(fixture.plugin); + when(excess.getUUID()).thenReturn("synthetic-capacity-excess"); + assertFalse(fixture.eligibility.retainTimedRetry(excess)); + assertTrue(fixture.eligibility.retainTimedRetry(fixture.user)); + verify(excess, never()).checkOfflineRewardsAsync(); + verify(excess, never()).checkDelayedTimedRewards(); + } + } + + @Test + void quitWaitsForResolvedLoginWithoutPollingAndRebindsLogicalIdentity() { + try (SignalFixture fixture = new SignalFixture()) { + Player previous = mock(Player.class); + when(previous.isOnline()).thenReturn(true); + when(fixture.user.getPlayer()).thenReturn(previous); + EligibilityAccess.deferred(fixture.eligibility, fixture.user); + fixture.eligibility.disconnected(previous); + fixture.clock.set(java.util.concurrent.TimeUnit.HOURS.toNanos(2)); + fixture.sweep.get().run(); + fixture.eligibility.signal(fixture.user).toCompletableFuture().join(); + verify(fixture.user, never()).checkDelayedTimedRewards(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + AdvancedCoreUser rebound = mock(AdvancedCoreUser.class); + when(rebound.getPlugin()).thenReturn(fixture.plugin); + String logicalId = fixture.user.getUUID(); + when(rebound.getUUID()).thenReturn(logicalId); + when(rebound.checkOfflineRewardsAsync()).thenAnswer(call -> { + EligibilityAccess.queued(fixture.eligibility, rebound); + return java.util.concurrent.CompletableFuture.completedFuture(null); + }); + Player replacement = mock(Player.class); + when(rebound.getPlayer()).thenReturn(replacement); + when(replacement.isOnline()).thenReturn(true); + fixture.eligibility.signalLogin(rebound, replacement).toCompletableFuture().join(); + fixture.eligibility.signal(replacement); + verify(rebound, times(2)).checkDelayedTimedRewards(); + verify(rebound, times(2)).checkOfflineRewardsAsync(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + verify(previous, never()).getUniqueId(); + verify(replacement, never()).getUniqueId(); + } + } + + @Test + void unknownHintRequiresAnOwnPluginBooleanLoginReceipt() { + for (int invalidReceipt = 0; invalidReceipt < 3; invalidReceipt++) { + try (SignalFixture fixture = new SignalFixture()) { + fixture.receipts.clear(); + org.bukkit.plugin.Plugin owner = invalidReceipt == 0 + ? mock(org.bukkit.plugin.Plugin.class) : fixture.plugin; + Object value = invalidReceipt == 1 ? "true" : invalidReceipt == 0; + fixture.receipts.add(new org.bukkit.metadata.FixedMetadataValue(owner, value)); + EligibilityAccess.queued(fixture.eligibility, fixture.user); + fixture.eligibility.signal(fixture.user).toCompletableFuture().join(); + verify(fixture.user, never()).hasPendingRewardReplay(); + verify(fixture.user, never()).checkDelayedTimedRewards(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + fixture.eligibility.signalLogin(fixture.user, fixture.player).toCompletableFuture().join(); + verify(fixture.user).checkDelayedTimedRewards(); + verify(fixture.user).checkOfflineRewardsAsync(); + } + } + } + + @Test + void acceptedLoginWithoutWorkRecordsOnlyItsSessionReceiptAndQuitRemovesOnlyOwnValue() { + try (SignalFixture fixture = new SignalFixture()) { + fixture.receipts.clear(); + org.bukkit.plugin.Plugin other = mock(org.bukkit.plugin.Plugin.class); + var foreign = new org.bukkit.metadata.FixedMetadataValue(other, true); + fixture.receipts.add(foreign); + fixture.eligibility.signalLogin(fixture.user, fixture.player).toCompletableFuture().join(); + assertTrue(fixture.receipts.stream().anyMatch(value -> + value.getOwningPlugin() == fixture.plugin && Boolean.TRUE.equals(value.value()))); + verify(fixture.user, never()).hasPendingRewardReplay(); + verify(fixture.user, never()).checkDelayedTimedRewards(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + assertNull(fixture.sweep.get()); + fixture.eligibility.disconnected(fixture.player); + assertEquals(List.of(foreign), fixture.receipts); + } + } + + @Test + void unknownHintDoesNotReadAReplacementFromThePreviousOwnerCallback() { + try (SignalFixture fixture = new SignalFixture()) { + EligibilityAccess.queued(fixture.eligibility, fixture.user); + var callback = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { callback.set(call.getArgument(1)); return null; }) + .when(fixture.scheduler).executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + clearInvocations(fixture.player); + var result = fixture.eligibility.signal(fixture.user).toCompletableFuture(); + assertFalse(result.isDone()); + Player replacement = mock(Player.class); + doReturn(replacement).when(fixture.user).getPlayer(); + callback.get().run(); + result.join(); + verify(fixture.player, never()).getMetadata(anyString()); + verify(replacement, never()).isOnline(); + verify(replacement, never()).getMetadata(anyString()); + verify(fixture.user, never()).hasPendingRewardReplay(); + verify(fixture.user, never()).checkDelayedTimedRewards(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + } + } + + @Test + void missingReceiptCannotAuthenticateAFreshDeferredHint() { + try (SignalFixture fixture = new SignalFixture(false)) { + EligibilityAccess.deferred(fixture.eligibility, fixture.user); + fixture.eligibility.signal(fixture.user).toCompletableFuture().join(); + verify(fixture.user, never()).checkDelayedTimedRewards(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + fixture.eligibility.signalLogin(fixture.user, fixture.player).toCompletableFuture().join(); + verify(fixture.user).checkDelayedTimedRewards(); + verify(fixture.user).checkOfflineRewardsAsync(); + } + } + + @Test + void freshDeferredHintCannotWakeEitherQueueBeforeAcceptedLogin() { + try (SignalFixture fixture = new SignalFixture(false)) { + fixture.eligibility.loginPending(fixture.player); + YamlConfiguration definition = new YamlConfiguration(); + definition.set("GameModes", List.of("SURVIVAL")); + assertFalse(fixture.eligibility.isEligible(definition, fixture.user), + "the native pending-login receipt must deny even an allowed mode"); + EligibilityAccess.deferred(fixture.eligibility, fixture.user); + assertFalse(fixture.eligibility.isEligible(definition, fixture.user), + "a new deferred hint is not an accepted-login receipt"); + fixture.eligibility.signal(fixture.user).toCompletableFuture().join(); + verify(fixture.user, never()).checkDelayedTimedRewards(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + fixture.eligibility.signalLogin(fixture.user, fixture.player).toCompletableFuture().join(); + assertTrue(fixture.eligibility.isEligible(definition, fixture.user)); + verify(fixture.user).checkDelayedTimedRewards(); + verify(fixture.user).checkOfflineRewardsAsync(); + } + } + + @Test + void allowedModeCannotBypassTheQuitGateBeforeAcceptedLogin() { + try (SignalFixture fixture = new SignalFixture()) { + Player previous = mock(Player.class); + when(previous.isOnline()).thenReturn(true); + when(fixture.user.getPlayer()).thenReturn(previous); + EligibilityAccess.deferred(fixture.eligibility, fixture.user); + fixture.eligibility.disconnected(previous); + Player replacement = mock(Player.class); + doReturn(replacement).when(fixture.user).getPlayer(); + when(replacement.isOnline()).thenReturn(true); + when(replacement.getGameMode()).thenReturn(GameMode.SURVIVAL); + YamlConfiguration definition = new YamlConfiguration(); + definition.set("GameModes", List.of("SURVIVAL")); + assertFalse(fixture.eligibility.isEligible(definition, fixture.user)); + EligibilityAccess.deferred(fixture.eligibility, fixture.user); + assertFalse(fixture.eligibility.isEligible(definition, fixture.user), + "retaining unfinished work must not authenticate the replacement session"); + fixture.eligibility.signalLogin(fixture.user, replacement).toCompletableFuture().join(); + assertTrue(fixture.eligibility.isEligible(definition, fixture.user)); + verify(replacement, never()).getUniqueId(); + } + } + + @Test + void loginChecksTheExactEventSessionOnlyOnItsOwner() { + try (SignalFixture fixture = new SignalFixture()) { + Player player = mock(Player.class); + when(fixture.user.getPlayer()).thenReturn(player); + EligibilityAccess.queued(fixture.eligibility, fixture.user); + var callback = new java.util.concurrent.atomic.AtomicReference(); + var onOwner = new java.util.concurrent.atomic.AtomicBoolean(); + when(player.isOnline()).thenAnswer(call -> { + assertTrue(onOwner.get(), "login must inspect the exact event player's owner"); + return true; + }); + doAnswer(call -> { callback.set(call.getArgument(1)); return null; }) + .when(fixture.scheduler).executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class), eq(player)); + var result = fixture.eligibility.signalLogin(fixture.user, player).toCompletableFuture(); + assertFalse(result.isDone()); + verify(player, never()).isOnline(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + onOwner.set(true); + callback.get().run(); + result.join(); + verify(fixture.user).checkDelayedTimedRewards(); + verify(fixture.user).checkOfflineRewardsAsync(); + verify(player, never()).getUniqueId(); + } + } + + @Test + void quitBeforeQueuedLoginCallbackRetainsTheAwaitingLoginHint() { + try (SignalFixture fixture = new SignalFixture()) { + Player player = mock(Player.class); + when(player.isOnline()).thenReturn(true); + when(fixture.user.getPlayer()).thenReturn(player); + EligibilityAccess.deferred(fixture.eligibility, fixture.user); + var callback = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { callback.set(call.getArgument(1)); return null; }) + .when(fixture.scheduler).executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class), eq(player)); + var result = fixture.eligibility.signalLogin(fixture.user, player).toCompletableFuture(); + fixture.eligibility.disconnected(player); + doReturn(false).when(player).isOnline(); + callback.get().run(); + result.join(); + fixture.clock.set(java.util.concurrent.TimeUnit.HOURS.toNanos(2)); + fixture.sweep.get().run(); + verify(fixture.user, never()).checkDelayedTimedRewards(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + assertNotNull(fixture.sweep.get(), "quit must retain the only timed wake-up hint"); + } + } + + @Test + void loginWithoutKnownHintDoesNotReadQueuesOrCreatePendingWork() { + try (SignalFixture fixture = new SignalFixture()) { + fixture.eligibility.signalLogin(fixture.user, mock(Player.class)).toCompletableFuture().join(); + verify(fixture.user, never()).checkDelayedTimedRewards(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + assertNull(fixture.sweep.get()); + } + } + + private static final class SignalFixture implements AutoCloseable { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final RewardHandler handler = mock(RewardHandler.class); + final com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + final AdvancedCoreUser user = mock(AdvancedCoreUser.class); + final Player player = mock(Player.class); + final java.util.ArrayList receipts = new java.util.ArrayList<>(); + final java.util.concurrent.ScheduledExecutorService worker = + mock(java.util.concurrent.ScheduledExecutorService.class); + final java.util.concurrent.atomic.AtomicReference sweep = + new java.util.concurrent.atomic.AtomicReference<>(); + final RewardEligibility eligibility; + final java.util.concurrent.atomic.AtomicLong clock = new java.util.concurrent.atomic.AtomicLong(); + + SignalFixture() { this(true); } + + SignalFixture(boolean acceptedLogin) { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getTimer()).thenReturn(worker); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { ((Runnable) call.getArgument(1)).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { ((Runnable) call.getArgument(1)).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + when(handler.getDelayedTimer()).thenReturn(worker); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn("synthetic-backoff-user"); + when(user.getPlayer()).thenReturn(player); + when(player.isOnline()).thenReturn(true); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(player.getMetadata(anyString())).thenAnswer(call -> new java.util.ArrayList<>(receipts)); + doAnswer(call -> { + org.bukkit.metadata.MetadataValue value = call.getArgument(1); + receipts.removeIf(previous -> previous.getOwningPlugin() == value.getOwningPlugin()); + receipts.add(value); + return null; + }).when(player).setMetadata(anyString(), any(org.bukkit.metadata.MetadataValue.class)); + doAnswer(call -> { receipts.removeIf(value -> value.getOwningPlugin() == call.getArgument(1)); return null; }) + .when(player).removeMetadata(anyString(), any(org.bukkit.plugin.Plugin.class)); + doAnswer(call -> { ((Runnable) call.getArgument(0)).run(); return null; }) + .when(worker).execute(any(Runnable.class)); + doAnswer(call -> { + sweep.set(call.getArgument(0)); + return mock(java.util.concurrent.ScheduledFuture.class); + }).when(worker).scheduleWithFixedDelay(any(Runnable.class), anyLong(), anyLong(), + any(java.util.concurrent.TimeUnit.class)); + eligibility = EligibilityAccess.create(plugin, handler, clock::get); + when(user.checkOfflineRewardsAsync()).thenAnswer(call -> { + EligibilityAccess.queued(eligibility, user); + return java.util.concurrent.CompletableFuture.completedFuture(null); + }); + if (acceptedLogin) eligibility.signalLogin(user, player).toCompletableFuture().join(); + } + + @Override public void close() { eligibility.close(); } + } + +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeCommandTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeCommandTest.java new file mode 100644 index 0000000000..982f4c6faf --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeCommandTest.java @@ -0,0 +1,286 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import com.bencodez.advancedcore.api.rewards.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +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.GameMode; +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.metadata.MetadataValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.advancedcore.api.misc.MiscUtils; +import com.bencodez.advancedcore.api.rewards.builtin.RewardCommands; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; + +import me.clip.placeholderapi.PlaceholderAPI; + +class RewardGamemodeCommandTest { + @TempDir File directory; + + @Test void consoleListDefersSecondCommandAndResumesOnce() throws Exception { + commandScenario("console", true); + } + + @Test void playerListDefersSecondCommandAndResumesOnce() throws Exception { + commandScenario("player", true); + } + + @Test void mixedSectionCarriesGuardFromConsoleToPlayerCommands() throws Exception { + commandScenario("mixed", true); + } + + @Test void mixedSectionKeepsCompletedPlayerPrefixAfterConsoleHandoff() throws Exception { + commandScenario("mixed-player", true); + } + + @Test void absentPolicyPreservesConsoleSequenceAfterModeChange() throws Exception { + commandScenario("console", false); + } + + @Test void absentPolicyPreservesPlayerSequenceAfterModeChange() throws Exception { + commandScenario("player", false); + } + + @Test void forcedOfflineConsoleBypassesLivePlayerPolicy() throws Exception { + commandScenario("forced-console", true); + } + + @Test void disconnectedConsoleSuffixIsRetained() throws Exception { + commandScenario("console", true, true); + } + + @Test void disconnectedPlayerCommandSuffixIsRetained() throws Exception { + commandScenario("player", true, true); + } + + @Test void disconnectedMixedCommandSuffixPreservesBothPrefixes() throws Exception { + commandScenario("mixed-player", true, true); + } + + private void commandScenario(String kind, boolean policy) throws Exception { + commandScenario(kind, policy, false); + } + + @Test void immediatelyRetiredConsoleSuffixIsRetained() throws Exception { commandScenario("console", true, false, 1); } + @Test void laterRetiredConsoleSuffixIsRetained() throws Exception { commandScenario("console", true, false, 2); } + @Test void immediatelyRetiredPlayerSuffixIsRetained() throws Exception { commandScenario("player", true, false, 1); } + @Test void laterRetiredPlayerSuffixIsRetained() throws Exception { commandScenario("player", true, false, 2); } + @Test void immediatelyRetiredMixedSuffixIsRetained() throws Exception { commandScenario("mixed-player", true, false, 1); } + @Test void laterRetiredMixedSuffixIsRetained() throws Exception { commandScenario("mixed-player", true, false, 2); } + + private void commandScenario(String kind, boolean policy, boolean disconnect) throws Exception { + commandScenario(kind, policy, disconnect, 0); + } + + private void commandScenario(String kind, boolean policy, boolean disconnect, int retirement) throws Exception { + boolean forcedOffline = kind.equals("forced-console"); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getDataFolder()).thenReturn(directory); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isOnlineMode()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(mock(UserDataManager.class)); + when(users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + UserData data = mock(UserData.class); + AtomicReference> persisted = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("OfflineRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(call -> new ArrayList<>(persisted.get())); + doAnswer(call -> { persisted.set(new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(eq("OfflineRewards"), any()); + doAnswer(call -> { persisted.set(new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(eq("OfflineRewards"), any(), eq(false)); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + ThreadLocal playerOwner = ThreadLocal.withInitial(() -> false); + BlockingQueue callbacks = new LinkedBlockingQueue<>(); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { onOwner(playerOwner, call.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + doAnswer(call -> { callbacks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + doAnswer(call -> { callbacks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTaskLater(eq(plugin), any(Runnable.class), anyLong()); + doAnswer(call -> { Runnable task = call.getArgument(1); callbacks.add(() -> onOwner(playerOwner, task)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Player.class)); + RetiringOwnerScheduler retiringScheduler = retirement == 0 ? null + : new RetiringOwnerScheduler(plugin, scheduler, + task -> callbacks.add(() -> onOwner(playerOwner, task)), callbacks::add); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }).when(timer).execute(any()); + Player player = mock(Player.class); + UUID id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + when(player.isOnline()).thenReturn(true); + HashMap loginReceipt = new HashMap<>(); + loginReceipt.put(plugin, new org.bukkit.metadata.FixedMetadataValue(plugin, Boolean.TRUE)); + when(player.getMetadata("AdvancedCoreRewardEligibilityLogin")) + .thenAnswer(call -> loginReceipt.isEmpty() ? List.of() : List.copyOf(loginReceipt.values())); + doAnswer(call -> { MetadataValue value = call.getArgument(1); + loginReceipt.put(value.getOwningPlugin(), value); return null; }) + .when(player).setMetadata(eq("AdvancedCoreRewardEligibilityLogin"), any(MetadataValue.class)); + doAnswer(call -> { loginReceipt.remove(call.getArgument(1)); return null; }) + .when(player).removeMetadata(eq("AdvancedCoreRewardEligibilityLogin"), any()); + AtomicReference mode = new AtomicReference<>(GameMode.SURVIVAL); + when(player.getGameMode()).thenAnswer(call -> { + assertTrue(playerOwner.get(), "gamemode must only be read on the player owner scheduler"); + return mode.get(); + }); + AdvancedCoreUser user = spy(new AdvancedCoreUser(plugin, id, "CommandPlayer")); + user.setData(data); + doReturn(forcedOffline ? null : player).when(user).getPlayer(); + doReturn(!forcedOffline).when(user).isOnline(); + AdvancedCorePlugin.setInstance(plugin); + RewardHandler handler = new RewardHandler(plugin); + when(plugin.getRewardHandler()).thenReturn(handler); + MiscUtils misc = MiscUtils.getInstance(); + Field miscPlugin = MiscUtils.class.getDeclaredField("plugin"); + miscPlugin.setAccessible(true); + Object previousPlugin = miscPlugin.get(misc); + miscPlugin.set(misc, plugin); + List dispatched = new ArrayList<>(); + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + when(server.dispatchCommand(eq(console), anyString())).thenAnswer(call -> { + String command = call.getArgument(1); + dispatched.add(command); + if (command.equals("first")) { + if (retiringScheduler != null) retiringScheduler.retireNext(retirement == 1); + else if (disconnect) when(player.isOnline()).thenReturn(false); + else mode.set(GameMode.CREATIVE); + } + return true; + }); + doAnswer(call -> { + String command = call.getArgument(0, String.class).substring(1); + dispatched.add(command); + if (command.equals("first")) { + if (retiringScheduler != null) retiringScheduler.retireNext(retirement == 1); + else if (disconnect) when(player.isOnline()).thenReturn(false); + else mode.set(GameMode.CREATIVE); + } + return null; + }).when(player).chat(anyString()); + try (var bukkit = mockStatic(Bukkit.class); + var placeholderProvider = mockStatic(PlaceholderAPI.class); + var builders = mockConstruction(ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF))) { + // Isolate the optional provider, preserving the real placeholder pipeline. + placeholderProvider.when(() -> PlaceholderAPI.setPlaceholders(any(OfflinePlayer.class), anyString())) + .thenAnswer(call -> call.getArgument(1, String.class)); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(forcedOffline ? null : player); + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + RewardCommands.registerCommands(handler, plugin); + YamlConfiguration definition = new YamlConfiguration(); + if (policy) definition.set("GameModes", List.of("SURVIVAL")); + if (forcedOffline) definition.set("ForceOffline", true); + if (kind.equals("console") || forcedOffline) definition.set("Commands", List.of("first", "second")); + else if (kind.equals("player")) definition.set("Commands.Player", new ArrayList<>(List.of("first", "second"))); + else if (kind.equals("mixed-player")) { + definition.set("Commands.Console", new ArrayList<>(List.of("console-prefix"))); + definition.set("Commands.Player", new ArrayList<>(List.of("first", "second"))); + } else { + definition.set("Commands.Console", new ArrayList<>(List.of("first"))); + definition.set("Commands.Player", new ArrayList<>(List.of("second"))); + } + Reward reward = new Reward("GamemodeCommand", definition).needsRewardFile(false); + // Bind the real reward explicitly; legacy BaseTest retains a static singleton mock. + EligibilityAccess.bind(reward, plugin); + EligibilityAccess.bind(reward.getConfig(), plugin); + handler.getRewards().add(reward); + RewardOptions options = new RewardOptions().setCheckTimed(false).setGiveOffline(true); + options.setAsyncReplayOccurrenceId("command-occurrence"); + CompletableFuture delivery = reward.giveRewardAsync(user, options).toCompletableFuture(); + drainUntilDone(callbacks, delivery); + if (policy && !forcedOffline) { + assertEquals(kind.equals("mixed-player") ? List.of("console-prefix", "first") : List.of("first"), + dispatched, "second command must wait for an allowed gamemode"); + assertEquals(1, persisted.get().size()); + mode.set(GameMode.SURVIVAL); + when(player.isOnline()).thenReturn(true); + var replay = (disconnect || retirement != 0 + ? handler.getRewardEligibility().signalLogin(user, player) + : handler.getRewardEligibility().signal(user)).toCompletableFuture(); + drainUntilDone(callbacks, replay); + } + assertEquals(kind.equals("mixed-player") ? List.of("console-prefix", "first", "second") + : List.of("first", "second"), dispatched); + assertTrue(persisted.get().isEmpty()); + var repeatedSignal = handler.getRewardEligibility().signal(user).toCompletableFuture(); + drainUntilDone(callbacks, repeatedSignal); + assertEquals(kind.equals("mixed-player") ? List.of("console-prefix", "first", "second") + : List.of("first", "second"), dispatched); + if (retiringScheduler != null) { + retiringScheduler.enqueueStaleOwnerCallback(); + Runnable stale = callbacks.poll(2, TimeUnit.SECONDS); + assertNotNull(stale); + stale.run(); + retiringScheduler.repeatRetirementSignal(); + assertEquals(kind.equals("mixed-player") ? List.of("console-prefix", "first", "second") + : List.of("first", "second"), dispatched); + assertTrue(persisted.get().isEmpty()); + } + assertTrue(callbacks.isEmpty()); + } finally { + miscPlugin.set(misc, previousPlugin); + handler.getRewardEligibility().close(); + handler.getDelayedTimer().shutdownNow(); + AdvancedCorePlugin.setInstance(null); + } + } + + private void drainUntilDone(BlockingQueue callbacks, CompletableFuture completion) throws Exception { + for (int attempts = 0; !completion.isDone() && attempts < 30; attempts++) { + Runnable task = callbacks.poll(2, TimeUnit.SECONDS); + assertNotNull(task, "command callback was not scheduled"); + task.run(); + } + completion.get(2, TimeUnit.SECONDS); + } + + private void onOwner(ThreadLocal owner, Runnable task) { + boolean previous = owner.get(); + owner.set(true); + try { task.run(); } finally { owner.set(previous); } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeEconomyTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeEconomyTest.java new file mode 100644 index 0000000000..9564efc6cf --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeEconomyTest.java @@ -0,0 +1,360 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import com.bencodez.advancedcore.api.rewards.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.ExecutionException; +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.GameMode; +import org.bukkit.OfflinePlayer; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.VaultHandler; +import com.bencodez.advancedcore.api.rewards.injected.RewardInject; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; + +import net.milkbowl.vault.economy.Economy; + +class RewardGamemodeEconomyTest { + @TempDir File directory; + + @Test void depositDefersAndKeepsCompletedNativePrefix() throws Exception { economyScenario(false, "policy"); } + @Test void withdrawalDefersAndKeepsCompletedNativePrefix() throws Exception { economyScenario(true, "policy"); } + @Test void absentPolicyPreservesDeposit() throws Exception { economyScenario(false, "none"); } + @Test void absentPolicyPreservesWithdrawal() throws Exception { economyScenario(true, "none"); } + @Test void configuredForceOfflinePreservesDeposit() throws Exception { economyScenario(false, "configured-force"); } + @Test void configuredForceOfflinePreservesWithdrawal() throws Exception { economyScenario(true, "configured-force"); } + @Test void optionForceOfflinePreservesDeposit() throws Exception { economyScenario(false, "option-force"); } + @Test void optionForceOfflinePreservesWithdrawal() throws Exception { economyScenario(true, "option-force"); } + + @Test void disconnectedDepositRemainsQueuedAndResumesOnce() throws Exception { + economyScenario(false, "policy", "disconnect"); + } + @Test void disconnectedWithdrawalRemainsQueuedAndResumesOnce() throws Exception { + economyScenario(true, "policy", "disconnect"); + } + @Test void reconnectedDepositRemainsQueuedAndResumesOnce() throws Exception { + economyScenario(false, "policy", "replacement"); + } + @Test void reconnectedWithdrawalRemainsQueuedAndResumesOnce() throws Exception { + economyScenario(true, "policy", "replacement"); + } + @Test void absentPolicyKeepsDisconnectedDeposit() throws Exception { + economyScenario(false, "none", "disconnect"); + } + @Test void absentPolicyKeepsDisconnectedWithdrawal() throws Exception { + economyScenario(true, "none", "disconnect"); + } + @Test void absentPolicyKeepsReconnectedDeposit() throws Exception { + economyScenario(false, "none", "replacement"); + } + @Test void absentPolicyKeepsReconnectedWithdrawal() throws Exception { + economyScenario(true, "none", "replacement"); + } + + @Test void immediatelyRetiredDepositRemainsQueuedAndResumesOnce() throws Exception { + economyScenario(false, "policy", "retired-immediate"); + } + @Test void immediatelyRetiredWithdrawalRemainsQueuedAndResumesOnce() throws Exception { + economyScenario(true, "policy", "retired-immediate"); + } + @Test void laterRetiredDepositRemainsQueuedAndResumesOnce() throws Exception { + economyScenario(false, "policy", "retired-later"); + } + @Test void laterRetiredWithdrawalRemainsQueuedAndResumesOnce() throws Exception { + economyScenario(true, "policy", "retired-later"); + } + @Test void retirementDuringShutdownRemainsDistinctFromEligibilityDeferral() throws Exception { + economyScenario(false, "policy", "retired-shutdown"); + } + @Test void rejectedOwnerTaskRemainsDistinctFromEligibilityDeferral() throws Exception { + economyScenario(true, "policy", "retired-rejection"); + } + @Test void absentPolicyDepositNeedsNoRetiringOwnerTask() throws Exception { + economyScenario(false, "none", "retired-immediate"); + } + @Test void absentPolicyWithdrawalNeedsNoRetiringOwnerTask() throws Exception { + economyScenario(true, "none", "retired-later"); + } + + @Test void repeatedRetiredDepositReplayRetainsItsWakeHint() throws Exception { + economyScenario(false, "policy", "retired-repeat"); + } + @Test void repeatedRetiredWithdrawalReplayRetainsItsWakeHint() throws Exception { + economyScenario(true, "policy", "retired-repeat"); + } + + private void economyScenario(boolean withdrawal, String policy) throws Exception { + economyScenario(withdrawal, policy, "mode"); + } + + private void economyScenario(boolean withdrawal, String policy, String transition) throws Exception { + // The legacy preparation path admits an absent player through configured ForceOffline only. + // Options.forceOffline remains an online control for bypassing the live policy. + boolean forcedOffline = policy.equals("configured-force"); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AtomicBoolean enabled = new AtomicBoolean(true); + when(plugin.isEnabled()).thenAnswer(call -> enabled.get()); + when(plugin.getDataFolder()).thenReturn(directory); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isOnlineMode()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(mock(UserDataManager.class)); + when(users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + UserData data = mock(UserData.class); + AtomicReference> persisted = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("OfflineRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(call -> new ArrayList<>(persisted.get())); + doAnswer(call -> { persisted.set(new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(eq("OfflineRewards"), any()); + doAnswer(call -> { persisted.set(new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(eq("OfflineRewards"), any(), eq(false)); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + ThreadLocal playerOwner = ThreadLocal.withInitial(() -> false); + BlockingQueue callbacks = new LinkedBlockingQueue<>(); + doAnswer(call -> { onScheduler(playerOwner, false, call.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { onScheduler(playerOwner, true, call.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + doAnswer(call -> { + Runnable task = call.getArgument(1); + callbacks.add(() -> onScheduler(playerOwner, false, task)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + doAnswer(call -> { + Runnable task = call.getArgument(1); + callbacks.add(() -> onScheduler(playerOwner, true, task)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Player.class)); + RetiringOwnerScheduler retiringScheduler = transition.startsWith("retired-") + ? new RetiringOwnerScheduler(plugin, scheduler, + task -> callbacks.add(() -> onScheduler(playerOwner, true, task)), + task -> callbacks.add(() -> onScheduler(playerOwner, false, task))) + : null; + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { + Runnable task = call.getArgument(0); + // A real worker executes the final ownership observation after the + // completed replay has released its serial tail, rather than reentrantly. + if (transition.equals("retired-repeat")) callbacks.add(() -> onScheduler(playerOwner, false, task)); + else task.run(); + return null; + }).when(timer).execute(any()); + Player player = mock(Player.class); + UUID id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + AtomicReference currentPlayer = new AtomicReference<>(forcedOffline ? null : player); + when(player.isOnline()).thenAnswer(call -> currentPlayer.get() == player); + when(player.getMetadata("AdvancedCoreRewardEligibilityLogin")).thenReturn( + List.of(new org.bukkit.metadata.FixedMetadataValue(plugin, Boolean.TRUE))); + AtomicReference mode = new AtomicReference<>(GameMode.SURVIVAL); + when(player.getGameMode()).thenAnswer(call -> { + assertTrue(playerOwner.get(), "gamemode must only be read on the player owner scheduler"); + return mode.get(); + }); + Player replacement = mock(Player.class); + when(replacement.getUniqueId()).thenReturn(id); + when(replacement.isOnline()).thenAnswer(call -> currentPlayer.get() == replacement); + when(replacement.getGameMode()).thenAnswer(call -> { + assertTrue(playerOwner.get(), "gamemode must only be read on the replacement owner scheduler"); + return mode.get(); + }); + OfflinePlayer account = mock(OfflinePlayer.class); + AdvancedCoreUser user = spy(new AdvancedCoreUser(plugin, id, "EconomyPlayer")); + user.setData(data); + doAnswer(call -> currentPlayer.get()).when(user).getPlayer(); + doAnswer(call -> currentPlayer.get() != null).when(user).isOnline(); + doReturn(account).when(user).getOfflinePlayer(); + Economy economy = mock(Economy.class); + VaultHandler vault = mock(VaultHandler.class); + when(plugin.getVaultHandler()).thenReturn(vault); + when(vault.getEcon()).thenReturn(economy); + List mutations = new ArrayList<>(); + when(economy.depositPlayer(eq(account), anyDouble())).thenAnswer(call -> { + assertFalse(playerOwner.get(), "economy provider must retain the global scheduler boundary"); + mutations.add("deposit:" + call.getArgument(1)); + return null; + }); + when(economy.withdrawPlayer(eq(account), anyDouble())).thenAnswer(call -> { + assertFalse(playerOwner.get(), "economy provider must retain the global scheduler boundary"); + mutations.add("withdraw:" + call.getArgument(1)); + return null; + }); + AdvancedCorePlugin.setInstance(plugin); + RewardHandler handler = new RewardHandler(plugin); + when(plugin.getRewardHandler()).thenReturn(handler); + AtomicInteger prefix = new AtomicInteger(); + AtomicInteger nativeRequests = new AtomicInteger(); + AtomicBoolean retireSuffixOnReplay = new AtomicBoolean(); + AtomicReference amount = new AtomicReference<>(withdrawal ? -5.0 : 5.0); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenAnswer(call -> currentPlayer.get()); + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + YamlConfiguration definition = new YamlConfiguration(); + if (!policy.equals("none")) definition.set("GameModes", List.of("SURVIVAL")); + if (policy.equals("configured-force")) definition.set("ForceOffline", true); + Reward reward = new Reward("GamemodeEconomy", definition).needsRewardFile(false); + // Bind both real objects explicitly; legacy BaseTest retains a static singleton mock. + EligibilityAccess.bind(reward, plugin); + EligibilityAccess.bind(reward.getConfig(), plugin); + handler.getRewards().add(reward); + handler.addInjectedReward(new RewardInject("Prefix") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser target, + ConfigurationSection ignoredData, HashMap placeholders) { + prefix.incrementAndGet(); + return null; + } + }); + handler.addInjectedReward(new RewardInject("EconomyActions") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser target, + ConfigurationSection ignoredData, HashMap placeholders) { + nativeRequests.incrementAndGet(); + target.giveMoney(2.0); + target.giveMoney(amount.get()); + // Arm only after replay reached the real injector. Its completed + // deposit is checkpointed, so the next owner task belongs to the suffix. + if (retireSuffixOnReplay.compareAndSet(true, false)) retiringScheduler.retireNext(true); + return null; + } + }); + RewardOptions options = new RewardOptions().setCheckTimed(false).setGiveOffline(true); + if (policy.equals("option-force")) options.forceOffline(); + options.setAsyncReplayOccurrenceId("economy-occurrence"); + var delivery = reward.giveRewardAsync(user, options).toCompletableFuture(); + for (int attempts = 0; mutations.isEmpty() && attempts < 15; attempts++) runNext(callbacks); + assertEquals(List.of("deposit:2.0"), mutations, "native prefix must complete before the mode transition"); + assertFalse(delivery.isDone()); + if (retiringScheduler != null) { + currentPlayer.set(replacement); + if (transition.equals("retired-rejection")) retiringScheduler.rejectNext(); + else { + if (transition.equals("retired-shutdown")) retiringScheduler.beforeRetirement(() -> enabled.set(false)); + retiringScheduler.retireNext(transition.equals("retired-immediate")); + } + } else if (transition.equals("mode")) mode.set(GameMode.CREATIVE); + else currentPlayer.set(transition.equals("disconnect") ? null : replacement); + if (transition.equals("retired-shutdown") || transition.equals("retired-rejection")) { + assertThrows(ExecutionException.class, () -> drainUntilDone(callbacks, delivery)); + assertEquals(List.of("deposit:2.0"), mutations); + assertTrue(persisted.get().isEmpty(), "shutdown and rejection must retain their existing failure behavior"); + retiringScheduler.enqueueStaleOwnerCallback(); + runNext(callbacks); + retiringScheduler.repeatRetirementSignal(); + assertEquals(List.of("deposit:2.0"), mutations); + assertTrue(callbacks.isEmpty()); + return; + } + drainUntilDone(callbacks, delivery); + if (policy.equals("policy")) { + assertEquals(List.of("deposit:2.0"), mutations, "ineligible economy action must not reach Vault"); + assertEquals(1, persisted.get().size()); + // An action that never began must not pin its old payload or repeat the completed prefix. + amount.set(withdrawal ? -7.0 : 7.0); + mode.set(GameMode.SURVIVAL); + if (!transition.equals("mode")) currentPlayer.set(replacement); + if (transition.equals("retired-repeat")) { + // This is the durable replay phase used by a fallback sweep: retire again + // while the player mode remains allowed, so no mode-denial check renews its hint. + retireSuffixOnReplay.set(true); + var retiredReplay = handler.getRewardEligibility() + .signalLogin(user, replacement).toCompletableFuture(); + drainUntilDone(callbacks, retiredReplay); + assertEquals(2, nativeRequests.get(), "the durable replay must reach its real native injector"); + assertFalse(retireSuffixOnReplay.get(), "retirement must be armed by the suffix injector"); + assertEquals(List.of("deposit:2.0"), mutations); + assertEquals(1, persisted.get().size()); + assertFalse(user.hasPendingRewardReplay(), "the failed replay must release its storage claim"); + java.lang.reflect.Field pending = RewardEligibility.class.getDeclaredField("pending"); + pending.setAccessible(true); + assertTrue(((java.util.Map) pending.get(handler.getRewardEligibility())).containsKey(user.getUUID()), + "a restored durable occurrence must retain its cached eligibility wake hint"); + } + // A replacement connection becomes eligible through the accepted + // login event, not merely by appearing in the player lookup. + var replay = transition.equals("mode") || transition.equals("retired-repeat") + ? handler.getRewardEligibility().signal(user).toCompletableFuture() + : handler.getRewardEligibility().signalLogin(user, replacement).toCompletableFuture(); + drainUntilDone(callbacks, replay); + } + String action = withdrawal ? "withdraw:" : "deposit:"; + String finalAmount = policy.equals("policy") ? "7.0" : "5.0"; + assertEquals(List.of("deposit:2.0", action + finalAmount), mutations); + assertEquals(1, prefix.get()); + assertTrue(persisted.get().isEmpty()); + var repeatedSignal = handler.getRewardEligibility().signal(user).toCompletableFuture(); + drainUntilDone(callbacks, repeatedSignal); + assertEquals(List.of("deposit:2.0", action + finalAmount), mutations); + assertTrue(callbacks.isEmpty()); + if (retiringScheduler != null && policy.equals("policy")) { + retiringScheduler.enqueueStaleOwnerCallback(); + runNext(callbacks); + retiringScheduler.repeatRetirementSignal(); + assertEquals(List.of("deposit:2.0", action + finalAmount), mutations); + assertTrue(persisted.get().isEmpty()); + assertTrue(callbacks.isEmpty()); + } + if (policy.endsWith("force") || policy.equals("none")) { + verify(player, never()).getGameMode(); + verify(replacement, never()).getGameMode(); + } + } finally { + handler.getRewardEligibility().close(); + handler.getDelayedTimer().shutdownNow(); + AdvancedCorePlugin.setInstance(null); + } + } + + private void drainUntilDone(BlockingQueue callbacks, CompletableFuture completion) throws Exception { + for (int attempts = 0; !completion.isDone() && attempts < 40; attempts++) runNext(callbacks); + completion.get(2, TimeUnit.SECONDS); + } + + private void runNext(BlockingQueue callbacks) throws Exception { + Runnable task = callbacks.poll(2, TimeUnit.SECONDS); + assertNotNull(task, "economy callback was not scheduled"); + task.run(); + } + + private void onScheduler(ThreadLocal owner, boolean playerOwner, Runnable task) { + boolean previous = owner.get(); + owner.set(playerOwner); + try { task.run(); } finally { owner.set(previous); } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeMutationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeMutationTest.java new file mode 100644 index 0000000000..34768eedcb --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeMutationTest.java @@ -0,0 +1,254 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import com.bencodez.advancedcore.api.rewards.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +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.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.metadata.MetadataValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.rewards.injected.RewardInject; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.data.ServerData; + +class RewardGamemodeMutationTest { + @TempDir File directory; + + @Test void experienceCallbackDefersAndKeepsCompletedLegacyAction() throws Exception { + mutationScenario(false, "GameModes", true); + } + + @Test void itemCallbackDefersAndKeepsCompletedLegacyAction() throws Exception { + mutationScenario(true, "GameModes", true); + } + + @Test void blacklistAlsoRechecksAtTheExperienceMutation() throws Exception { + mutationScenario(false, "BlackListedGameModes", true); + } + + @Test void allowedItemCallbackStillCompletes() throws Exception { + mutationScenario(true, "GameModes", false); + } + + @Test void absentPolicyKeepsExistingItemDeliveryAfterModeChange() throws Exception { + mutationScenario(true, null, true); + } + + @Test void disconnectedExperienceCallbackRetainsUnstartedSuffix() throws Exception { + mutationScenario(false, "GameModes", false, true); + } + + @Test void disconnectedItemCallbackRetainsUnstartedSuffix() throws Exception { + mutationScenario(true, "GameModes", false, true); + } + + private void mutationScenario(boolean itemAction, String rule, boolean changeMode) throws Exception { + mutationScenario(itemAction, rule, changeMode, false); + } + + @Test void immediatelyRetiredExperienceRetainsUnstartedSuffix() throws Exception { + mutationScenario(false, "GameModes", false, false, 1); + } + @Test void laterRetiredExperienceRetainsUnstartedSuffix() throws Exception { + mutationScenario(false, "GameModes", false, false, 2); + } + @Test void immediatelyRetiredItemsRetainUnstartedSuffix() throws Exception { + mutationScenario(true, "GameModes", false, false, 1); + } + @Test void laterRetiredItemsRetainUnstartedSuffix() throws Exception { + mutationScenario(true, "GameModes", false, false, 2); + } + private void mutationScenario(boolean itemAction, String rule, boolean changeMode, boolean disconnect) throws Exception { + mutationScenario(itemAction, rule, changeMode, disconnect, 0); + } + private void mutationScenario(boolean itemAction, String rule, boolean changeMode, boolean disconnect, + int retirement) throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getDataFolder()).thenReturn(directory); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isOnlineMode()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(mock(UserDataManager.class)); + when(users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + UserData data = mock(UserData.class); + AtomicReference> persisted = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("OfflineRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(call -> new ArrayList<>(persisted.get())); + doAnswer(call -> { persisted.set(new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(eq("OfflineRewards"), any()); + doAnswer(call -> { persisted.set(new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(eq("OfflineRewards"), any(), eq(false)); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + BlockingQueue callbacks = new LinkedBlockingQueue<>(); + doAnswer(call -> { callbacks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Player.class)); + RetiringOwnerScheduler retiringScheduler = retirement == 0 ? null + : new RetiringOwnerScheduler(plugin, scheduler, callbacks::add, callbacks::add); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }).when(timer).execute(any()); + ServerData serverData = mock(ServerData.class); + when(plugin.getServerDataFile()).thenReturn(serverData); + when(serverData.getData()).thenReturn(new YamlConfiguration()); + FullInventoryHandler inventoryHandler = itemAction ? new FullInventoryHandler(plugin) : null; + when(plugin.getFullInventoryHandler()).thenReturn(inventoryHandler); + Player player = mock(Player.class); + UUID id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + when(player.isOnline()).thenReturn(true); + HashMap loginReceipt = new HashMap<>(); + loginReceipt.put(plugin, new org.bukkit.metadata.FixedMetadataValue(plugin, Boolean.TRUE)); + when(player.getMetadata("AdvancedCoreRewardEligibilityLogin")) + .thenAnswer(call -> loginReceipt.isEmpty() ? List.of() : List.copyOf(loginReceipt.values())); + doAnswer(call -> { MetadataValue value = call.getArgument(1); + loginReceipt.put(value.getOwningPlugin(), value); return null; }) + .when(player).setMetadata(eq("AdvancedCoreRewardEligibilityLogin"), any(MetadataValue.class)); + doAnswer(call -> { loginReceipt.remove(call.getArgument(1)); return null; }) + .when(player).removeMetadata(eq("AdvancedCoreRewardEligibilityLogin"), any()); + if (retiringScheduler != null) doAnswer(call -> { + retiringScheduler.retireNext(retirement == 1); + return null; + }).when(player).giveExp(2); + AtomicReference mode = new AtomicReference<>(GameMode.SURVIVAL); + when(player.getGameMode()).thenAnswer(call -> mode.get()); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + ItemStack item = new ItemStack(Material.STONE); + when(inventory.addItem(item)).thenReturn(new HashMap<>()); + AdvancedCoreUser user = spy(new AdvancedCoreUser(plugin, id, "MutationPlayer")); + user.setData(data); + doReturn(player).when(user).getPlayer(); + doReturn(true).when(user).isOnline(); + AdvancedCorePlugin.setInstance(plugin); + RewardHandler handler = new RewardHandler(plugin); + when(plugin.getRewardHandler()).thenReturn(handler); + AtomicInteger prefix = new AtomicInteger(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + YamlConfiguration definition = new YamlConfiguration(); + if (rule != null) definition.set(rule, + List.of(rule.equals("GameModes") ? "SURVIVAL" : "CREATIVE")); + Reward reward = new Reward("GamemodeMutation", definition).needsRewardFile(false); + // Legacy BaseTest can retain a static singleton mock across classes. Bind this + // real reward and its configuration to the fixture, as runtime construction does. + EligibilityAccess.bind(reward, plugin); + EligibilityAccess.bind(reward.getConfig(), plugin); + handler.getRewards().add(reward); + handler.addInjectedReward(new RewardInject("Prefix") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser target, + ConfigurationSection ignoredData, HashMap placeholders) { + prefix.incrementAndGet(); + return null; + } + }); + handler.addInjectedReward(new RewardInject("NativeActions") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser target, + ConfigurationSection ignoredData, HashMap placeholders) { + target.giveExp(2); + if (itemAction) target.giveItem(item); + else target.giveExp(3); + return null; + } + }); + RewardOptions options = new RewardOptions().setCheckTimed(false).setGiveOffline(true); + options.setAsyncReplayOccurrenceId("mutation-occurrence"); + var delivery = reward.giveRewardAsync(user, options).toCompletableFuture(); + assertFalse(callbacks.isEmpty(), () -> "Native mutation was not scheduled; plugin calls: " + + mockingDetails(plugin).getInvocations()); + runNext(callbacks); // Commit the first legacy action while still eligible. + verify(player).giveExp(2); + if (retiringScheduler == null) { + Runnable finalMutation = callbacks.poll(2, TimeUnit.SECONDS); + assertNotNull(finalMutation, "native mutation was not scheduled"); + assertFalse(delivery.isDone()); + if (changeMode) mode.set(GameMode.CREATIVE); + if (disconnect) when(player.isOnline()).thenReturn(false); + finalMutation.run(); + } else { + for (int attempts = 0; !delivery.isDone() && attempts < 10; attempts++) runNext(callbacks); + } + delivery.get(2, TimeUnit.SECONDS); + boolean deferred = rule != null && (changeMode || disconnect || retirement != 0); + if (deferred) { + assertEquals(1, persisted.get().size()); + verify(player, never()).giveExp(3); + verify(inventory, never()).addItem(any(ItemStack[].class)); + if (inventoryHandler != null) assertTrue(inventoryHandler.getItems().isEmpty()); + mode.set(GameMode.SURVIVAL); + when(player.isOnline()).thenReturn(true); + var replay = (disconnect || retirement != 0 + ? handler.getRewardEligibility().signalLogin(user, player) + : handler.getRewardEligibility().signal(user)).toCompletableFuture(); + runNext(callbacks); + replay.get(2, TimeUnit.SECONDS); + } + if (retiringScheduler != null) { + retiringScheduler.enqueueStaleOwnerCallback(); + runNext(callbacks); + retiringScheduler.repeatRetirementSignal(); + } + assertTrue(persisted.get().isEmpty()); + assertEquals(1, prefix.get()); + verify(player, times(1)).giveExp(2); + if (itemAction) verify(inventory, times(1)).addItem(item); + else verify(player, times(1)).giveExp(3); + assertTrue(callbacks.isEmpty()); + } finally { + handler.getRewardEligibility().close(); + handler.getDelayedTimer().shutdownNow(); + if (inventoryHandler != null) inventoryHandler.shutdown(); + AdvancedCorePlugin.setInstance(null); + } + } + + private void runNext(BlockingQueue callbacks) throws InterruptedException { + Runnable callback = callbacks.poll(2, TimeUnit.SECONDS); + assertNotNull(callback, "native mutation was not scheduled"); + callback.run(); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeReplayTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeReplayTest.java new file mode 100644 index 0000000000..dc12fba8cc --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeReplayTest.java @@ -0,0 +1,744 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import com.bencodez.advancedcore.api.rewards.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +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.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.metadata.MetadataValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.injected.RewardInject; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.simpleapi.array.ArrayUtils; + +class RewardGamemodeReplayTest { + @TempDir File directory; + + @Test void immediateTransitionKeepsCompletedPrefix() { replayScenario(false, false); } + @Test void immediateTransitionSurvivesHandlerReplacement() { replayScenario(false, true); } + @Test void yieldedTransitionKeepsCompletedPrefix() { replayScenario(true, false); } + @Test void yieldedTransitionSurvivesHandlerReplacement() { replayScenario(true, true); } + + @Test void approvedChanceSurvivesDeferralBeforeFirstEffect() throws Exception { + firstEffectChanceScenario(false); + } + + @Test void approvedChanceSurvivesFirstEffectDeferralAndHandlerReplacement() throws Exception { + firstEffectChanceScenario(true); + } + + private void firstEffectChanceScenario(boolean restart) throws Exception { + firstEffectChanceScenario(restart, false, false); + } + + @Test void defaultModesGuardFirstEffectAndRetainChanceOnReplay() throws Exception { + firstEffectChanceScenario(false, true, false); + } + + @Test void removingDefaultAfterDeferralKeepsApprovedChance() throws Exception { + firstEffectChanceScenario(true, true, true); + } + + @Test void realGamemodeRequirementRetainsChanceWhenItDefersAfterTheRoll() throws Exception { + requirementOrderChanceScenario(false); + } + + @Test void inheritedDefaultRetainsChanceWhenRealGamemodeRequirementDefers() throws Exception { + requirementOrderChanceScenario(true); + } + + private void requirementOrderChanceScenario(boolean defaults) throws Exception { + try (NestedFixture fixture = new NestedFixture(); + var utility = mockStatic(com.bencodez.advancedcore.api.misc.MiscUtils.class, CALLS_REAL_METHODS)) { + if (defaults) fixture.defaults.set("DefaultRewardGameModes", List.of("SURVIVAL")); + Reward reward = fixture.reward("RequirementOrderChance", !defaults); + reward.getConfig().getConfigData().set("Chance", 50); + AtomicInteger rolls = new AtomicInteger(), effects = new AtomicInteger(); + var random = mock(com.bencodez.advancedcore.api.misc.MiscUtils.class, CALLS_REAL_METHODS); + utility.when(com.bencodez.advancedcore.api.misc.MiscUtils::getInstance).thenReturn(random); + doAnswer(call -> { + fixture.mode.set(GameMode.CREATIVE); + return rolls.incrementAndGet() == 1; + }).when(random).checkChance(50, 100); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementChance.register(fixture.handler, fixture.plugin); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementGameMode.register(fixture.handler, fixture.plugin); + fixture.handler.sortInjectedRequirements(); + assertEquals(List.of("Chance", "GameModes"), fixture.handler.getInjectedRequirements().stream() + .map(com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject::getPath).toList()); + fixture.handler.addInjectedReward(new RewardInject("Effect") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward current, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + effects.incrementAndGet(); return null; + } + }); + String occurrence = UUID.randomUUID().toString(); + RewardOptions options = new RewardOptions().setCheckTimed(false).setGiveOffline(true); + options.setAsyncReplayOccurrenceId(occurrence); + reward.giveRewardAsync(fixture.user, options).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, rolls.get()); + assertEquals(0, effects.get()); + assertEquals(1, fixture.stored("OfflineRewards").size()); + assertTrue(fixture.stored("OfflineRewards").get(0).contains(occurrence)); + fixture.mode.set(GameMode.SURVIVAL); + fixture.handler.getRewardEligibility().signal(fixture.user).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, rolls.get(), "A passed chance must survive a later reattemptable gamemode requirement"); + assertEquals(1, effects.get()); + assertTrue(fixture.stored("OfflineRewards").isEmpty()); + fixture.handler.getRewardEligibility().signal(fixture.user).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, effects.get()); + } + } + + @Test void freshFailedChanceRemainsTerminalAndDoesNotAcquireApproval() throws Exception { + try (NestedFixture fixture = new NestedFixture(); + var utility = mockStatic(com.bencodez.advancedcore.api.misc.MiscUtils.class, CALLS_REAL_METHODS)) { + fixture.defaults.set("DefaultRewardGameModes", List.of("SURVIVAL")); + Reward reward = fixture.reward("FreshFailedChance", false); + reward.getConfig().getConfigData().set("Chance", 50); + var random = mock(com.bencodez.advancedcore.api.misc.MiscUtils.class); + utility.when(com.bencodez.advancedcore.api.misc.MiscUtils::getInstance).thenReturn(random); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementChance.register(fixture.handler, fixture.plugin); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementGameMode.register(fixture.handler, fixture.plugin); + fixture.handler.sortInjectedRequirements(); + AtomicInteger effects = new AtomicInteger(); + fixture.handler.addInjectedReward(new RewardInject("Effect") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward current, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + effects.incrementAndGet(); return null; + } + }); + RewardOptions options = new RewardOptions().setCheckTimed(false).setGiveOffline(true); + reward.giveRewardAsync(fixture.user, options).toCompletableFuture().get(5, TimeUnit.SECONDS); + verify(random).checkChance(50, 100); + assertEquals(0, effects.get()); + assertTrue(fixture.stored("OfflineRewards").isEmpty()); + assertTrue(options.getPlaceholders().keySet().stream() + .noneMatch(key -> key.startsWith("__advancedcore_replay_chance_"))); + } + } + + @Test void timedRequirementDeferralPersistsApprovedChanceBeforeFirstEffect() throws Exception { + timedRequirementChanceScenario(false); + } + + @Test void timedRequirementApprovalSurvivesHandlerReplacement() throws Exception { + timedRequirementChanceScenario(true); + } + + private void timedRequirementChanceScenario(boolean restart) throws Exception { + queuedRequirementChanceScenario(restart, QueuedDeferral.MODE, true); + } + + @Test void timedPauseAfterPassedChancePreservesApproval() throws Exception { + queuedRequirementChanceScenario(false, QueuedDeferral.PAUSE, true); + } + + @Test void timedRetryableRequirementAfterChancePreservesApproval() throws Exception { + queuedRequirementChanceScenario(false, QueuedDeferral.REQUIREMENT, true); + } + + @Test void timedRequirementExceptionAfterPassedChancePreservesApproval() throws Exception { + queuedRequirementChanceScenario(false, QueuedDeferral.REQUIREMENT_FAILURE, true); + } + + @Test void timedRejectedNativeHandoffAfterPassedChancePreservesApproval() throws Exception { + queuedRequirementChanceScenario(false, QueuedDeferral.NATIVE_REJECTION, true); + } + + @Test void offlineRequirementExceptionAfterPassedChancePreservesApproval() throws Exception { + queuedRequirementChanceScenario(false, QueuedDeferral.REQUIREMENT_FAILURE, false); + } + + private enum QueuedDeferral { MODE, PAUSE, REQUIREMENT, REQUIREMENT_FAILURE, NATIVE_REJECTION } + + private void queuedRequirementChanceScenario(boolean restart, QueuedDeferral reason, boolean timed) throws Exception { + boolean pause = reason == QueuedDeferral.PAUSE; + boolean retryable = reason == QueuedDeferral.REQUIREMENT; + boolean providerFailure = reason == QueuedDeferral.REQUIREMENT_FAILURE; + boolean nativeRejection = reason == QueuedDeferral.NATIVE_REJECTION; + try (NestedFixture fixture = new NestedFixture(); + var utility = mockStatic(com.bencodez.advancedcore.api.misc.MiscUtils.class, CALLS_REAL_METHODS)) { + fixture.defaults.set("DefaultRewardGameModes", List.of("SURVIVAL")); + Reward reward = fixture.reward("TimedRequirementChance", false); + reward.getConfig().getConfigData().set("Chance", 50); + AtomicInteger rolls = new AtomicInteger(), effects = new AtomicInteger(); + var paused = new java.util.concurrent.atomic.AtomicBoolean(); + var allowRequirement = new java.util.concurrent.atomic.AtomicBoolean(!(retryable || providerFailure || nativeRejection)); + var rejectNative = new java.util.concurrent.atomic.AtomicBoolean(); + when(fixture.plugin.getOptions().isPauseRewards()).thenAnswer(call -> paused.get()); + var nativeScheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { + if (rejectNative.getAndSet(false)) + throw new java.util.concurrent.RejectedExecutionException("Synthetic native handoff rejection"); + call.getArgument(1, Runnable.class).run(); return null; + }).when(nativeScheduler).executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + var random = mock(com.bencodez.advancedcore.api.misc.MiscUtils.class, CALLS_REAL_METHODS); + utility.when(com.bencodez.advancedcore.api.misc.MiscUtils::getInstance).thenReturn(random); + doAnswer(call -> { + if (pause) paused.set(true); + else if (reason == QueuedDeferral.MODE) fixture.mode.set(GameMode.CREATIVE); + return rolls.incrementAndGet() == 1; + }).when(random).checkChance(50, 100); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementChance.register(fixture.handler, fixture.plugin); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementGameMode.register(fixture.handler, fixture.plugin); + if (retryable || providerFailure || nativeRejection) fixture.handler.getInjectedRequirements().add( + new com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject("AfterChance") { + @Override public boolean onRequirementRequest(Reward current, AdvancedCoreUser user, + ConfigurationSection data, RewardOptions options) { + if (providerFailure && !allowRequirement.get()) throw new IllegalStateException("Synthetic requirement failure"); + if (nativeRejection && !allowRequirement.get()) rejectNative.set(true); + return !retryable || allowRequirement.get(); + } + }.alwaysForceNoData().allowReattempt().priority(100)); + fixture.handler.sortInjectedRequirements(); + fixture.handler.addInjectedReward(new RewardInject("Effect") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward current, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + effects.incrementAndGet(); return null; + } + }); + HashMap placeholders = new HashMap<>(); + placeholders.put("__AdvancedCoreAsyncReplayKey", "Scoped/TimedChance"); + placeholders.put("payload", "retained"); + if (timed) { + fixture.user.addTimedReward(reward, placeholders, System.currentTimeMillis() - 1_000); + fixture.user.checkDelayedTimedRewards(); + } else { + RewardOptions options = new RewardOptions().setCheckTimed(false).setGiveOffline(true).withPlaceHolder(placeholders); + options.setAsyncReplayOccurrenceId(UUID.randomUUID().toString()); + fixture.user.addOfflineRewards(reward, placeholders, options); + fixture.user.checkOfflineRewardsAsync().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + String queue = timed ? "TimedRewards" : "OfflineRewards"; + assertEquals(1, rolls.get()); + assertEquals(0, effects.get()); + assertEquals(1, fixture.stored(queue).size()); + String retained = fixture.stored(queue).get(0); + HashMap snapshot = ArrayUtils.fromString(retained.split("%placeholders%", 2)[1] + .split("%ExecutionTime/%", 2)[0]); + assertTrue(snapshot.entrySet().stream().anyMatch(entry -> + entry.getKey().startsWith("__advancedcore_replay_chance_") && "1".equals(entry.getValue())), + "A metadata-only queued deferral must persist its approved chance"); + assertFalse(retained.split("%placeholders%", 2)[0].contains("%asyncprogress%"), + "Retaining metadata must not invent completed effect stages"); + assertEquals("Scoped/TimedChance", snapshot.get("__AdvancedCoreAsyncReplayKey")); + assertEquals("retained", snapshot.get("payload")); + if (restart) { + fixture.restart(); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementChance.register(fixture.handler, fixture.plugin); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementGameMode.register(fixture.handler, fixture.plugin); + fixture.handler.sortInjectedRequirements(); + } + fixture.mode.set(GameMode.SURVIVAL); + paused.set(false); + allowRequirement.set(true); + if (timed && reason != QueuedDeferral.MODE) { + HashMap queuedTimes = fixture.user.getTimedRewards(); + assertTrue(queuedTimes.values().stream().allMatch(time -> time > System.currentTimeMillis()), + "Ordinary deferrals must retain the existing future retry deadline"); + queuedTimes.replaceAll((entry, time) -> System.currentTimeMillis() - 1_000); + fixture.user.setTimedRewards(queuedTimes); + } + if (timed) fixture.user.checkDelayedTimedRewards(); + else fixture.user.checkOfflineRewardsAsync().toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, rolls.get(), "Timed replay must not reroll the approved occurrence"); + assertEquals(1, effects.get()); + assertTrue(fixture.stored(queue).isEmpty()); + if (timed) fixture.user.checkDelayedTimedRewards(); + else fixture.user.checkOfflineRewardsAsync().toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, effects.get()); + } + } + + @Test void globalDefaultRetainsAndResumesDueTimedOccurrence() throws Exception { + try (NestedFixture fixture = new NestedFixture()) { + fixture.defaults.set("DefaultRewardGameModes", List.of("SURVIVAL")); + fixture.mode.set(GameMode.CREATIVE); + Reward reward = fixture.reward("DefaultTimed", false); + AtomicInteger effects = new AtomicInteger(); + fixture.handler.addInjectedReward(new RewardInject("Effect") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward current, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + effects.incrementAndGet(); return null; + } + }); + fixture.user.addTimedReward(reward, new HashMap<>(), System.currentTimeMillis() - 1_000); + fixture.user.checkDelayedTimedRewards(); + assertEquals(0, effects.get()); + assertEquals(1, fixture.stored("TimedRewards").size()); + String retained = fixture.stored("TimedRewards").get(0); + fixture.user.checkDelayedTimedRewards(); + assertEquals(1, fixture.stored("TimedRewards").size()); + assertEquals(retained.replaceAll("%asyncretry%[0-9]+", "%asyncretry%"), + fixture.stored("TimedRewards").get(0).replaceAll("%asyncretry%[0-9]+", "%asyncretry%"), + "Retry attempts may advance; occurrence identity, due time and payload must remain unchanged"); + assertEquals(0, effects.get()); + fixture.mode.set(GameMode.SURVIVAL); + fixture.handler.getRewardEligibility().signal(fixture.user).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, effects.get()); + assertTrue(fixture.stored("TimedRewards").isEmpty()); + fixture.user.checkDelayedTimedRewards(); + assertEquals(1, effects.get()); + } + } + + private void firstEffectChanceScenario(boolean restart, boolean defaults, boolean removeDefault) throws Exception { + try (NestedFixture fixture = new NestedFixture()) { + if (defaults) fixture.defaults.set("DefaultRewardGameModes", List.of("SURVIVAL")); + Reward reward = fixture.reward("FirstEffectChance", !defaults); + AtomicInteger rolls = new AtomicInteger(), effects = new AtomicInteger(); + var chance = new com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject("Chance") { + @Override public boolean onRequirementRequest(Reward current, AdvancedCoreUser user, + ConfigurationSection data, RewardOptions options) { + fixture.mode.set(GameMode.CREATIVE); + return rolls.incrementAndGet() == 1; + } + }.alwaysForceNoData(); + fixture.handler.getInjectedRequirements().add(chance); + fixture.handler.addInjectedReward(new RewardInject("Effect") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward current, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + effects.incrementAndGet(); return null; + } + }); + String occurrence = UUID.randomUUID().toString(); + RewardOptions options = new RewardOptions().setCheckTimed(false).setGiveOffline(true); + options.setAsyncReplayOccurrenceId(occurrence); + reward.giveRewardAsync(fixture.user, options).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, rolls.get()); + assertEquals(0, effects.get()); + assertEquals(1, fixture.stored("OfflineRewards").size()); + assertTrue(fixture.stored("OfflineRewards").get(0).contains(occurrence)); + assertTrue(options.getAsyncReplayProgress().values().stream().allMatch(count -> count == 0), + "No effect has begun when the approved occurrence is deferred"); + if (restart) { + fixture.restart(); + fixture.handler.getInjectedRequirements().add(chance); + } + fixture.mode.set(GameMode.SURVIVAL); + if (removeDefault) fixture.defaults.set("DefaultRewardGameModes", null); + fixture.handler.getRewardEligibility().signal(fixture.user).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, rolls.get(), "Persisted approval must prevent a second chance roll"); + assertEquals(1, effects.get()); + assertTrue(fixture.stored("OfflineRewards").isEmpty()); + fixture.handler.getRewardEligibility().signal(fixture.user).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, effects.get(), "A duplicate signal must not repeat the effect"); + } + } + + private void replayScenario(boolean yield, boolean restart) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getDataFolder()).thenReturn(directory); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isOnlineMode()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + UserManager users = mock(UserManager.class); + UserDataManager storage = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(storage); + when(users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + UserData data = mock(UserData.class); + AtomicReference> persisted = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("OfflineRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(call -> new ArrayList<>(persisted.get())); + doAnswer(call -> { persisted.set(new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(eq("OfflineRewards"), any()); + doAnswer(call -> { persisted.set(new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(eq("OfflineRewards"), any(), eq(false)); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Player.class)); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }).when(timer).execute(any()); + Player player = mock(Player.class); + UUID id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + when(player.isOnline()).thenReturn(true); + HashMap loginReceipt = new HashMap<>(); + when(player.getMetadata("AdvancedCoreRewardEligibilityLogin")) + .thenAnswer(call -> loginReceipt.isEmpty() ? List.of() : List.copyOf(loginReceipt.values())); + doAnswer(call -> { MetadataValue value = call.getArgument(1); + loginReceipt.put(value.getOwningPlugin(), value); return null; }) + .when(player).setMetadata(eq("AdvancedCoreRewardEligibilityLogin"), any(MetadataValue.class)); + doAnswer(call -> { loginReceipt.remove(call.getArgument(1)); return null; }) + .when(player).removeMetadata(eq("AdvancedCoreRewardEligibilityLogin"), any()); + AtomicReference mode = new AtomicReference<>(GameMode.SURVIVAL); + when(player.getGameMode()).thenAnswer(call -> mode.get()); + AdvancedCoreUser user = spy(new AdvancedCoreUser(plugin, id, "GamemodePlayer")); + user.setData(data); + doReturn(player).when(user).getPlayer(); + doReturn(true).when(user).isOnline(); + AdvancedCorePlugin.setInstance(plugin); + RewardHandler handler = new RewardHandler(plugin); + when(plugin.getRewardHandler()).thenReturn(handler); + AtomicInteger prefix = new AtomicInteger(), remainder = new AtomicInteger(); + java.util.List debug = new java.util.ArrayList<>(); + doAnswer(call -> { debug.add(call.getArgument(0)); return null; }).when(plugin).debug(anyString()); + var boundary = new java.util.concurrent.CompletableFuture(); + RewardHandler recovered = null; + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + YamlConfiguration definition = new YamlConfiguration(); + definition.set("GameModes", List.of("SURVIVAL")); + Reward reward = new Reward("GamemodeReplay", definition).needsRewardFile(false); + // Legacy BaseTest can retain a static singleton mock across classes. Bind this + // real reward and its configuration to the fixture, as runtime construction does. + EligibilityAccess.bind(reward, plugin); + EligibilityAccess.bind(reward.getConfig(), plugin); + handler.getRewards().add(reward); + handler.addInjectedReward(effect("Prefix", prefix, mode, GameMode.CREATIVE, false, yield ? boundary : null)); + handler.addInjectedReward(effect("Remainder", remainder, mode, GameMode.CREATIVE, true, null)); + handler.getRewardEligibility().signalLogin(user, player).toCompletableFuture().join(); + RewardOptions options = new RewardOptions().setCheckTimed(false).setGiveOffline(true); + options.setAsyncReplayOccurrenceId("gamemode-occurrence"); + options.setAsyncReplayKey("GamemodeParent/GamemodeChild"); + var delivery = reward.giveRewardAsync(user, options).toCompletableFuture(); + if (yield) { + assertFalse(delivery.isDone(), () -> "Unexpected early completion: " + debug); + assertEquals(1, prefix.get(), () -> "Reward did not execute prefix: " + debug); + assertEquals(0, remainder.get()); + mode.set(GameMode.CREATIVE); + boundary.complete(null); + } + delivery.join(); + assertEquals(1, prefix.get(), () -> "Reward did not execute prefix: " + debug); + assertEquals(0, remainder.get()); + assertEquals(1, persisted.get().size()); + assertTrue(persisted.get().get(0).contains("gamemode-occurrence")); + AdvancedCoreUser resumed = user; + RewardHandler current = handler; + if (restart) { + handler.getRewardEligibility().close(); + recovered = new RewardHandler(plugin); + recovered.getInjectedRewards().addAll(handler.getInjectedRewards()); + recovered.getRewards().add(reward); + when(plugin.getRewardHandler()).thenReturn(recovered); + current = recovered; + resumed = spy(new AdvancedCoreUser(plugin, id, "GamemodePlayer")); + resumed.setData(data); + doReturn(player).when(resumed).getPlayer(); + doReturn(true).when(resumed).isOnline(); + } + mode.set(GameMode.SURVIVAL); + current.getRewardEligibility().signal(resumed).toCompletableFuture().join(); + assertEquals(1, prefix.get(), () -> "Reward did not execute prefix: " + debug); + assertEquals(1, remainder.get()); + current.getRewardEligibility().signal(resumed).toCompletableFuture().join(); + assertEquals(1, remainder.get()); + assertTrue(persisted.get().isEmpty()); + } finally { + handler.getRewardEligibility().close(); + handler.getDelayedTimer().shutdownNow(); + if (recovered != null) { recovered.getRewardEligibility().close(); recovered.getDelayedTimer().shutdownNow(); } + AdvancedCorePlugin.setInstance(null); + } + } + + @Test void offlineParentKeepsItsKeyWhenChildDefers() throws Exception { + nestedReplayScenario(false, false, false); + } + + @Test void offlineParentKeepsItsKeyAcrossHandlerReplacement() throws Exception { + nestedReplayScenario(false, false, true); + } + + @Test void timedParentKeepsItsKeyWhenChildDefers() throws Exception { + nestedReplayScenario(true, false, false); + } + + @Test void timedParentKeepsItsKeyAcrossHandlerReplacement() throws Exception { + nestedReplayScenario(true, false, true); + } + + @Test void independentlyQueuedChildKeepsItsOwnKey() throws Exception { + nestedReplayScenario(false, true, false); + } + + @Test void independentlyQueuedChildKeepsItsOwnKeyAcrossHandlerReplacement() throws Exception { + nestedReplayScenario(false, true, true); + } + + private void nestedReplayScenario(boolean timed, boolean independentChild, boolean restart) throws Exception { + try (NestedFixture fixture = new NestedFixture()) { + Reward parent = fixture.reward("Parent", !independentChild); + Reward child = fixture.reward("Child", true); + AtomicInteger parentPrefix = new AtomicInteger(), childPrefix = new AtomicInteger(); + AtomicInteger parentRemainder = new AtomicInteger(), childRemainder = new AtomicInteger(); + CompletableFuture childBoundary = new CompletableFuture<>(); + fixture.handler.addInjectedReward(new RewardInject("Prefix") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + (reward == parent ? parentPrefix : childPrefix).incrementAndGet(); + return null; + } + }); + fixture.handler.addInjectedReward(new RewardInject("Nested") { + @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 (reward != parent) return CompletableFuture.completedFuture(null); + RewardOptions options = Reward.withReplayState(new RewardOptions().setCheckTimed(false) + .withPlaceHolder(placeholders), Reward.currentReplayState(), Reward.currentReplayKey(), + "only", Reward.currentReplayOccurrenceId()); + return child.giveRewardAsync(user, options).thenApply(ignored -> null); + } + }); + fixture.handler.addInjectedReward(new RewardInject("Transition") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + Object result = onRewardRequest(reward, user, data, placeholders); + return reward == child && !independentChild ? childBoundary + : CompletableFuture.completedFuture(result); + } + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (reward == child) fixture.mode.set(GameMode.CREATIVE); + return null; + } + }); + fixture.handler.addInjectedReward(new RewardInject("Remainder") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + (reward == parent ? parentRemainder : childRemainder).incrementAndGet(); + return null; + } + }); + String parentKey = "Original/Parent"; + String childKey = parentKey + "/1/only"; + RewardOptions parentOptions = new RewardOptions().setCheckTimed(false); + parentOptions.setAsyncReplayKey(parentKey); + parentOptions.setAsyncReplayOccurrenceId("nested-occurrence"); + CompletionStage delivery = null; + if (independentChild) { + parent.giveRewardAsync(fixture.user, parentOptions).toCompletableFuture().get(5, TimeUnit.SECONDS); + } else if (timed) { + HashMap placeholders = new HashMap<>(); + placeholders.put("__AdvancedCoreAsyncReplayKey", parentKey); + fixture.user.setTimedRewards(new HashMap<>(java.util.Map.of("Parent%placeholders%" + + ArrayUtils.makeString(placeholders), System.currentTimeMillis() - 1_000))); + fixture.user.checkDelayedTimedRewards(); + } else { + fixture.user.addOfflineRewards(parent, parentOptions.getPlaceholders(), parentOptions); + delivery = fixture.user.checkOfflineRewardsAsync(); + } + if (!independentChild) { + try { + assertFalse(childBoundary.isDone()); + assertEquals(1, childPrefix.get()); + ArrayList checkpointed = fixture.stored(timed ? "TimedRewards" : "OfflineRewards"); + assertEquals(1, checkpointed.size()); + String snapshot = checkpointed.get(0).split("%placeholders%", 2)[1] + .split("%ExecutionTime/%", 2)[0]; + assertEquals(parentKey, ArrayUtils.fromString(snapshot).get("__AdvancedCoreAsyncReplayKey"), + "An in-flight child's checkpoint belongs to the durable parent"); + } finally { + childBoundary.complete(null); + } + if (delivery != null) delivery.toCompletableFuture().get(5, TimeUnit.SECONDS); + } + assertEquals(1, parentPrefix.get()); + assertEquals(1, childPrefix.get()); + assertEquals(0, childRemainder.get()); + assertEquals(independentChild ? 1 : 0, parentRemainder.get()); + ArrayList pending = fixture.stored(timed ? "TimedRewards" : "OfflineRewards"); + assertEquals(1, pending.size()); + String placeholderText = pending.get(0).split("%placeholders%", 2)[1] + .split("%ExecutionTime/%", 2)[0]; + assertEquals(independentChild ? childKey : parentKey, + ArrayUtils.fromString(placeholderText).get("__AdvancedCoreAsyncReplayKey"), + "A checkpoint must retain the execution key of its own queue entry"); + if (restart) fixture.restart(); + fixture.mode.set(GameMode.SURVIVAL); + if (timed) { + HashMap due = fixture.user.getTimedRewards(); + due.replaceAll((entry, time) -> System.currentTimeMillis() - 1_000); + fixture.user.setTimedRewards(due); + fixture.user.checkDelayedTimedRewards(); + fixture.user.checkDelayedTimedRewards(); + } else { + fixture.handler.getRewardEligibility().signal(fixture.user).toCompletableFuture() + .get(5, TimeUnit.SECONDS); + fixture.handler.getRewardEligibility().signal(fixture.user).toCompletableFuture() + .get(5, TimeUnit.SECONDS); + } + assertEquals(1, parentPrefix.get(), "The parent's completed prefix must not run twice"); + assertEquals(1, childPrefix.get(), "The child's completed prefix must not run twice"); + assertEquals(1, childRemainder.get(), "The child's unfinished work must resume"); + assertEquals(1, parentRemainder.get(), "The parent must finish after its child"); + assertTrue(fixture.stored("OfflineRewards").isEmpty()); + assertTrue(fixture.stored("TimedRewards").isEmpty()); + } + } + + private final class NestedFixture implements AutoCloseable { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final UserData data = mock(UserData.class); + final HashMap> persisted = new HashMap<>(); + final YamlConfiguration defaults = new YamlConfiguration(); + final Player player = mock(Player.class); + final UUID id = UUID.randomUUID(); + final AtomicReference mode = new AtomicReference<>(GameMode.SURVIVAL); + final HashMap loginReceipt = new HashMap<>(); + final org.mockito.MockedStatic bukkit = mockStatic(Bukkit.class); + RewardHandler handler; + AdvancedCoreUser user; + + NestedFixture() { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getDataFolder()).thenReturn(directory); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isOnlineMode()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + var yaml = mock(com.bencodez.simpleapi.file.YMLConfig.class); + when(yaml.getData()).thenReturn(defaults); + when(config.getYmlConfig()).thenReturn(yaml); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(mock(UserDataManager.class)); + when(users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + when(data.getStringList(anyString(), eq(UserDataFetchMode.DEFAULT))) + .thenAnswer(call -> new ArrayList<>(stored(call.getArgument(0)))); + doAnswer(call -> { persisted.put(call.getArgument(0), new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(anyString(), any()); + doAnswer(call -> { persisted.put(call.getArgument(0), new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(anyString(), any(), eq(false)); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Player.class)); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }).when(timer).execute(any()); + when(player.getUniqueId()).thenReturn(id); + when(player.isOnline()).thenReturn(true); + when(player.getGameMode()).thenAnswer(call -> mode.get()); + when(player.getMetadata("AdvancedCoreRewardEligibilityLogin")) + .thenAnswer(call -> loginReceipt.isEmpty() ? List.of() : List.copyOf(loginReceipt.values())); + doAnswer(call -> { MetadataValue value = call.getArgument(1); + loginReceipt.put(value.getOwningPlugin(), value); return null; }) + .when(player).setMetadata(eq("AdvancedCoreRewardEligibilityLogin"), any(MetadataValue.class)); + doAnswer(call -> { loginReceipt.remove(call.getArgument(1)); return null; }) + .when(player).removeMetadata(eq("AdvancedCoreRewardEligibilityLogin"), any()); + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + AdvancedCorePlugin.setInstance(plugin); + handler = new RewardHandler(plugin); + when(plugin.getRewardHandler()).thenReturn(handler); + user = newUser(); + handler.getRewardEligibility().signalLogin(user, player).toCompletableFuture().join(); + } + + ArrayList stored(String path) { return persisted.getOrDefault(path, new ArrayList<>()); } + + AdvancedCoreUser newUser() { + AdvancedCoreUser created = spy(new AdvancedCoreUser(plugin, id, "NestedPlayer")); + created.setData(data); + doReturn(player).when(created).getPlayer(); + doReturn(true).when(created).isOnline(); + return created; + } + + Reward reward(String name, boolean configured) { + YamlConfiguration definition = new YamlConfiguration(); + if (configured) definition.set("GameModes", List.of("SURVIVAL")); + Reward reward = new Reward(name, definition).needsRewardFile(false); + EligibilityAccess.bind(reward, plugin); + EligibilityAccess.bind(reward.getConfig(), plugin); + handler.getRewards().add(reward); + return reward; + } + + void restart() { + RewardHandler previous = handler; + previous.getRewardEligibility().close(); + previous.getDelayedTimer().shutdownNow(); + handler = new RewardHandler(plugin); + handler.getRewards().addAll(previous.getRewards()); + handler.getInjectedRewards().addAll(previous.getInjectedRewards()); + when(plugin.getRewardHandler()).thenReturn(handler); + user = newUser(); + } + + @Override public void close() { + handler.getRewardEligibility().close(); + handler.getDelayedTimer().shutdownNow(); + AdvancedCorePlugin.setInstance(null); + bukkit.close(); + } + } + + private RewardInject effect(String path, AtomicInteger count, AtomicReference mode, + GameMode transition, boolean remainder, java.util.concurrent.CompletableFuture boundary) { + return new RewardInject(path) { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public java.util.concurrent.CompletionStage onRewardRequestAsync(Reward reward, + AdvancedCoreUser user, ConfigurationSection data, HashMap placeholders) { + if (boundary != null) { count.incrementAndGet(); return boundary; } + return java.util.concurrent.CompletableFuture.completedFuture(onRewardRequest(reward, user, data, placeholders)); + } + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + count.incrementAndGet(); + if (!remainder) mode.set(transition); + return null; + } + }; + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeTimedTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeTimedTest.java new file mode 100644 index 0000000000..6b58fb865b --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeTimedTest.java @@ -0,0 +1,1441 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import com.bencodez.advancedcore.api.rewards.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.event.EventHandler; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.injected.RewardInject; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.PersistedQueueReference; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.misc.PlayerManager; +import com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent; +import com.bencodez.advancedcore.listeners.RewardEligibilityListener; + +class RewardGamemodeTimedTest { + @TempDir File directory; + + @Test void noDataNativeJoinAllowsAnEligibleRewardWithoutALoginEvent() throws Exception { + try (Fixture fixture = new Fixture()) { + when(fixture.plugin.isLoadUserData()).thenReturn(false); + useWorkerContext(fixture); + fixture.onOwner(() -> fixture.player.removeMetadata("AdvancedCoreRewardEligibilityLogin", fixture.plugin)); + RewardEligibilityListener listener = new RewardEligibilityListener(fixture.plugin); + fixture.onOwner(() -> listener.onNativeJoin(new org.bukkit.event.player.PlayerJoinEvent(fixture.player, ""))); + Reward reward = fixture.reward("Due", true); + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions().setGiveOffline(false)).toCompletableFuture(); + fixture.drain(); + delivery.get(2, TimeUnit.SECONDS); + assertEquals(1, fixture.prefix.get()); + verify(fixture.player).giveExp(3); + verify(fixture.plugin.getUserManager(), never()).getUserAsync(anyString(), any(), any()); + } + } + + @Test void noDataNativeReconnectResumesTheSameDeferredTimedOccurrence() throws Exception { + try (Fixture fixture = new Fixture()) { + when(fixture.plugin.isLoadUserData()).thenReturn(false); + useWorkerContext(fixture); + fixture.queueWorkers.set(true); + RewardEligibilityListener listener = new RewardEligibilityListener(fixture.plugin); + fixture.onOwner(() -> listener.onNativeJoin(new org.bukkit.event.player.PlayerJoinEvent(fixture.player, ""))); + fixture.mode.set(GameMode.CREATIVE); + Reward reward = fixture.reward("Due", true); + fixture.user.addTimedReward(reward, new HashMap<>(), System.currentTimeMillis() - 10_000); + String originalOccurrence = occurrence(fixture.onlyTimedKey()); + fixture.user.checkDelayedTimedRewards(); + fixture.drainQueuedWork(); + assertEquals(originalOccurrence, occurrence(fixture.onlyTimedKey())); + fixture.onOwner(() -> listener.onQuit(new PlayerQuitEvent(fixture.player, ""))); + Player replacement = mock(Player.class); + installPlayerMetadata(replacement); + when(replacement.getUniqueId()).thenReturn(fixture.id); + when(replacement.getName()).thenReturn("TimedPlayer"); + when(replacement.isOnline()).thenReturn(true); + when(replacement.getGameMode()).thenAnswer(call -> { + assertTrue(fixture.playerOwner.get()); + return GameMode.SURVIVAL; + }); + doReturn(replacement).when(fixture.user).getPlayer(); + fixture.bukkit.when(() -> Bukkit.getPlayer(fixture.id)).thenReturn(replacement); + fixture.onOwner(() -> listener.onNativeJoin(new org.bukkit.event.player.PlayerJoinEvent(replacement, ""))); + fixture.drainQueuedWork(); + verify(replacement).giveExp(3); + verify(fixture.player, never()).giveExp(anyInt()); + assertEquals(1, fixture.prefix.get()); + assertTrue(fixture.user.getTimedRewards().isEmpty()); + verify(fixture.plugin.getUserManager(), never()).getUserAsync(anyString(), any(), any()); + fixture.onOwner(() -> listener.onNativeJoin(new org.bukkit.event.player.PlayerJoinEvent(replacement, ""))); + fixture.drainQueuedWork(); + verify(replacement, times(1)).giveExp(3); + assertEquals(1, fixture.prefix.get()); + } + } + + @Test void customRequirementsAndPreprocessingStayOffThePlayerOwner() throws Exception { + try (Fixture fixture = new Fixture()) { + var scheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { + boolean previous = fixture.playerOwner.get(); + fixture.playerOwner.set(false); + try { call.getArgument(1, Runnable.class).run(); } + finally { fixture.playerOwner.set(previous); } + return null; + }).when(scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + AtomicInteger requirements = new AtomicInteger(), placeholders = new AtomicInteger(); + fixture.handler.getPlaceholders().add(new RewardPlaceholderHandle("SyntheticValue") { + @Override public String getValue(Reward reward, AdvancedCoreUser user) { + assertFalse(fixture.playerOwner.get(), "preprocessing may use storage or external providers"); + placeholders.incrementAndGet(); + return "value"; + } + }.preProcess()); + fixture.handler.addInjectedRequirements(new com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject("SyntheticStorage") { + @Override public boolean onRequirementRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, RewardOptions options) { + assertFalse(fixture.playerOwner.get(), "custom requirements must retain the reward worker"); + requirements.incrementAndGet(); + return true; + } + }); + Reward reward = fixture.reward("Due", true); + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture(); + fixture.drain(); + delivery.get(2, TimeUnit.SECONDS); + assertEquals(1, requirements.get()); + assertEquals(1, placeholders.get()); + assertTrue(reward.canGiveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture().join()); + assertEquals(2, requirements.get(), "read-only eligibility uses the same worker contract"); + } + } + + @Test void nativeRequirementsReturnToTheWorkerBeforeTheNextCustomRequirement() throws Exception { + try (Fixture fixture = new Fixture()) { + var scheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { + boolean previous = fixture.playerOwner.get(); + fixture.playerOwner.set(false); + try { call.getArgument(1, Runnable.class).run(); } + finally { fixture.playerOwner.set(previous); } + return null; + }).when(scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + var order = new ArrayList(); + fixture.handler.addInjectedRequirements(new com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject("NativePredicate") { + @Override public boolean requiresPlayerThread() { return true; } + @Override public void prepareAsyncRequest(Reward reward, ConfigurationSection data, RewardOptions options) { + assertFalse(fixture.playerOwner.get(), "file preparation belongs on the worker"); + order.add("prepare"); + } + @Override public boolean onRequirementRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, RewardOptions options) { + assertTrue(fixture.playerOwner.get()); + order.add("native"); + return true; + } + }); + fixture.handler.addInjectedRequirements(new com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject("StoragePredicate") { + @Override public boolean onRequirementRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, RewardOptions options) { + assertFalse(fixture.playerOwner.get()); + order.add("storage"); + return true; + } + }); + Reward reward = fixture.reward("Due", true); + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture(); + fixture.drain(); + delivery.get(2, TimeUnit.SECONDS); + assertEquals(List.of("prepare", "native", "storage"), order); + order.clear(); + assertTrue(reward.canGiveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture().join()); + assertEquals(List.of("prepare", "native", "storage"), order); + } + } + + @Test void configuredJavascriptRequirementRunsOnTheWorker() throws Exception { + try (Fixture fixture = new Fixture(); + var engines = mockConstruction(com.bencodez.advancedcore.api.javascript.JavascriptEngine.class, + withSettings().defaultAnswer(RETURNS_SELF), (engine, context) -> { + when(engine.getBooleanValue(anyString())).thenAnswer(call -> { + assertFalse(fixture.playerOwner.get(), "configured scripts must not block the player owner"); + return true; + }); + })) { + var scheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { + boolean previous = fixture.playerOwner.get(); + fixture.playerOwner.set(false); + try { call.getArgument(1, Runnable.class).run(); } + finally { fixture.playerOwner.set(previous); } + return null; + }).when(scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + doReturn(mock(org.bukkit.OfflinePlayer.class)).when(fixture.user).getOfflinePlayer(); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementJavascript.register(fixture.handler, fixture.plugin); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementGameMode.register(fixture.handler, fixture.plugin); + Reward reward = fixture.reward("Due", true); + reward.getConfig().getConfigData().set("JavascriptExpression", "true"); + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture(); + fixture.drain(); + delivery.get(2, TimeUnit.SECONDS); + assertEquals(1, engines.constructed().size()); + verify(engines.constructed().getFirst()).getBooleanValue("true"); + } + } + + @Test void allowedGameModeKeepsDefaultRewardInjectionOnTheWorker() throws Exception { + configuredGameModeRewardInjectionRunsOffOwner("GameModes", GameMode.SURVIVAL, "SURVIVAL"); + } + + @Test void blacklistedGameModeKeepsDefaultRewardInjectionOnTheWorker() throws Exception { + configuredGameModeRewardInjectionRunsOffOwner("BlackListedGameModes", GameMode.SURVIVAL, "CREATIVE"); + } + + private void configuredGameModeRewardInjectionRunsOffOwner(String path, GameMode currentMode, + String configuredMode) throws Exception { + try (Fixture fixture = new Fixture()) { + useWorkerContext(fixture); + fixture.mode.set(currentMode); + doAnswer(call -> { assertTrue(fixture.playerOwner.get(), "native XP mutation requires its owner"); return null; }) + .when(fixture.player).giveExp(7); + AtomicInteger injected = new AtomicInteger(); + fixture.handler.addInjectedReward(new RewardInject("WorkerBoundary") { + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + assertFalse(fixture.playerOwner.get(), "default reward injections must run on the worker"); + injected.incrementAndGet(); + user.giveExp(7); + return null; + } + }); + Reward reward = fixture.reward("Due", false); + reward.getConfig().getConfigData().set(path, List.of(configuredMode)); + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture(); + fixture.drain(); + delivery.get(2, TimeUnit.SECONDS); + assertEquals(1, injected.get()); + verify(fixture.player, atLeastOnce()).getGameMode(); + verify(fixture.player).giveExp(7); + } + } + + @Test void configuredJavascriptRewardRunsOnTheWorker() throws Exception { + try (Fixture fixture = new Fixture(); + var engines = mockConstruction(com.bencodez.advancedcore.api.javascript.JavascriptEngine.class, + withSettings().defaultAnswer(RETURNS_SELF), (engine, context) -> { + doAnswer(call -> { + assertFalse(fixture.playerOwner.get(), "reward scripts must not block the player owner"); + return null; + }).when(engine).execute(anyString()); + })) { + useWorkerContext(fixture); + doReturn(mock(org.bukkit.OfflinePlayer.class)).when(fixture.user).getOfflinePlayer(); + com.bencodez.advancedcore.api.rewards.builtin.RewardJavascript.register(fixture.handler, fixture.plugin); + Reward reward = fixture.reward("Due", true); + reward.getConfig().getConfigData().set("Javascripts", new ArrayList<>(List.of("true"))); + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture(); + fixture.drain(); + delivery.get(2, TimeUnit.SECONDS); + assertEquals(1, engines.constructed().size()); + verify(engines.constructed().getFirst()).execute("true"); + } + } + + @Test void configuredInjectionKeepsTheResolvedPlayerSessionAcrossWorkerBoundary() throws Exception { + try (Fixture fixture = new Fixture()) { + useWorkerContext(fixture); + fixture.handler.getInjectedRewards().clear(); + AtomicReference current = new AtomicReference<>(fixture.player); + doAnswer(call -> current.get()).when(fixture.user).getPlayer(); + doAnswer(call -> current.get() != null).when(fixture.user).isOnline(); + fixture.bukkit.when(() -> Bukkit.getPlayer(fixture.id)).thenAnswer(call -> current.get()); + Player replacement = mock(Player.class); + installPlayerMetadata(replacement); + when(replacement.getUniqueId()).thenReturn(fixture.id); + when(replacement.isOnline()).thenReturn(true); + when(replacement.getGameMode()).thenAnswer(call -> { + assertTrue(fixture.playerOwner.get()); + return GameMode.SURVIVAL; + }); + var replaceOnHandoff = new java.util.concurrent.atomic.AtomicBoolean(); + var replaced = new java.util.concurrent.atomic.AtomicBoolean(); + var scheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { + Runnable task = call.getArgument(1, Runnable.class); + boolean ownerHandoff = fixture.playerOwner.get(); + fixture.workers.add(() -> { + if (ownerHandoff && replaceOnHandoff.compareAndSet(true, false)) { + current.set(replacement); + replaced.set(true); + } + boolean previous = fixture.playerOwner.get(); + fixture.playerOwner.set(false); + try { task.run(); } finally { fixture.playerOwner.set(previous); } + }); + return null; + }).when(scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + AtomicReference> guardCheck = new AtomicReference<>(); + fixture.handler.addInjectedReward(new RewardInject("SessionBoundary") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public java.util.concurrent.CompletionStage onRewardRequestAsync(Reward reward, + AdvancedCoreUser user, ConfigurationSection data, HashMap placeholders) { + assertFalse(fixture.playerOwner.get()); + AdvancedCoreUser.PlayerEligibilityGuard guard = user.capturePlayerEligibilityGuard(); + assertNotNull(guard); + var check = guard.checkAsync(); + guardCheck.set(check); + return check.thenApply(ignored -> null); + } + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { return null; } + }); + Reward reward = fixture.reward("SessionBoundary", true); + var unchanged = reward.giveRewardAsync(fixture.user, new RewardOptions().setGiveOffline(false)); + fixture.drainQueuedWork(); + unchanged.toCompletableFuture().get(2, TimeUnit.SECONDS); + assertNotNull(guardCheck.get()); + guardCheck.set(null); + replaceOnHandoff.set(true); + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions().setGiveOffline(false)); + fixture.drainQueuedWork(); + assertTrue(replaced.get(), "replace only after owner resolution and before the injection worker"); + assertNotNull(guardCheck.get(), "the real configured injection must execute"); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> guardCheck.get().toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertTrue(Reward.isPlayerEligibilityDeferred(failure)); + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + verify(replacement, never()).giveExp(anyInt()); + } + } + + @Test void unconfiguredAwaitedInjectionRetainsItsEstablishedOwnerBoundary() throws Exception { + try (Fixture fixture = new Fixture()) { + useWorkerContext(fixture); + AtomicInteger calls = new AtomicInteger(); + fixture.handler.addInjectedReward(new RewardInject("AwaitedControl") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + assertTrue(fixture.playerOwner.get(), "unconfigured awaited delivery keeps its existing boundary"); + calls.incrementAndGet(); + return null; + } + }); + Reward reward = fixture.reward("Due", false); + reward.getConfig().getConfigData().set("AwaitedControl", true); + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture(); + fixture.drain(); + delivery.get(2, TimeUnit.SECONDS); + assertEquals(1, calls.get()); + } + } + + @Test void builtInPermissionRequirementReadsOnlinePlayerOnOwnerForGiveAndEligibility() throws Exception { + try (Fixture fixture = new Fixture()) { + var scheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { + boolean previous = fixture.playerOwner.get(); + fixture.playerOwner.set(false); + try { call.getArgument(1, Runnable.class).run(); } + finally { fixture.playerOwner.set(previous); } + return null; + }).when(scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + when(fixture.player.hasPermission("rewards.due")).thenAnswer(call -> { + assertTrue(fixture.playerOwner.get(), "online permission checks require the owning scheduler"); + return true; + }); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementPermission + .register(fixture.handler, fixture.plugin); + Reward reward = fixture.permissionReward("rewards.due"); + + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture(); + fixture.drain(); + delivery.get(2, TimeUnit.SECONDS); + assertTrue(reward.canGiveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture().join()); + verify(fixture.player, times(2)).hasPermission("rewards.due"); + } + } + + @Test void builtInPermissionRequirementHonorsDeniedAndNegatedOnlinePermissionOnOwner() throws Exception { + try (Fixture fixture = new Fixture()) { + var scheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { + boolean previous = fixture.playerOwner.get(); + fixture.playerOwner.set(false); + try { call.getArgument(1, Runnable.class).run(); } + finally { fixture.playerOwner.set(previous); } + return null; + }).when(scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + when(fixture.player.hasPermission(anyString())).thenAnswer(call -> { + assertTrue(fixture.playerOwner.get(), "online permission checks require the owning scheduler"); + return false; + }); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementPermission + .register(fixture.handler, fixture.plugin); + Reward denied = fixture.permissionReward("rewards.denied"); + assertFalse(denied.canGiveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture().join()); + Reward negated = fixture.permissionReward("!rewards.denied"); + assertTrue(negated.canGiveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture().join()); + verify(fixture.player, times(2)).hasPermission("rewards.denied"); + } + } + + @Test void onlineVaultGroupRunsOnOwnerForGiveAndEligibility() throws Exception { + try (Fixture fixture = new Fixture()) { + useWorkerContext(fixture); + var permissions = installVault(fixture); + AtomicReference owner = new AtomicReference<>(); + when(permissions.getPrimaryGroup(fixture.player)).thenAnswer(call -> { + owner.set(fixture.playerOwner.get()); + return "member"; + }); + Reward reward = fixture.reward("Due", true); + reward.getConfig().getConfigData().set("VaultGroup", "member"); + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions().setGiveOffline(false)).toCompletableFuture(); + fixture.drain(); + delivery.get(2, TimeUnit.SECONDS); + assertEquals(Boolean.TRUE, owner.get()); + assertTrue(reward.canGiveRewardAsync(fixture.user, new RewardOptions().setGiveOffline(false)).toCompletableFuture().get(2, TimeUnit.SECONDS)); + verify(permissions, times(2)).getPrimaryGroup(fixture.player); + verify(permissions, never()).getPrimaryGroup(isNull(), any(org.bukkit.OfflinePlayer.class)); + } + } + + @Test void giveOfflineVaultGroupStaysOnWorkerEvenWithAConnectedPlayer() throws Exception { + try (Fixture fixture = new Fixture()) { + useWorkerContext(fixture); + var permissions = installVault(fixture); + var offline = mock(org.bukkit.OfflinePlayer.class); + doReturn(offline).when(fixture.user).getOfflinePlayer(); + AtomicReference owner = new AtomicReference<>(); + when(permissions.getPrimaryGroup(null, offline)).thenAnswer(call -> { + owner.set(fixture.playerOwner.get()); + return "member"; + }); + Reward reward = fixture.reward("Due", true); + reward.getConfig().getConfigData().set("VaultGroup", "member"); + RewardOptions options = new RewardOptions().setGiveOffline(true); + assertTrue(reward.canGiveRewardAsync(fixture.user, options).toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertEquals(Boolean.FALSE, owner.get()); + verify(permissions, never()).getPrimaryGroup(fixture.player); + } + } + + @Test void offlinePermissionProviderStaysOnWorkerAndPreservesNegation() throws Exception { + try (Fixture fixture = new Fixture()) { + useWorkerContext(fixture); + var permissions = installVault(fixture); + when(fixture.plugin.getOptions().isUseVaultPermissions()).thenReturn(true); + when(permissions.isEnabled()).thenReturn(true); + doReturn(null).when(fixture.user).getPlayer(); + doReturn(false).when(fixture.user).isOnline(); + fixture.bukkit.when(() -> Bukkit.getPlayer(fixture.id)).thenReturn(null); + var world = mock(org.bukkit.World.class); + when(world.getName()).thenReturn("synthetic-world"); + fixture.bukkit.when(Bukkit::getWorlds).thenReturn(List.of(world)); + var offline = mock(org.bukkit.OfflinePlayer.class); + fixture.bukkit.when(() -> Bukkit.getOfflinePlayer(fixture.id)).thenReturn(offline); + AtomicReference owner = new AtomicReference<>(); + when(permissions.playerHas(eq("synthetic-world"), eq(offline), anyString())).thenAnswer(call -> { + owner.set(fixture.playerOwner.get()); + return false; + }); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementPermission.register(fixture.handler, fixture.plugin); + assertFalse(fixture.permissionReward("rewards.offline").canGiveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertTrue(fixture.permissionReward("!rewards.offline").canGiveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertEquals(Boolean.FALSE, owner.get()); + verify(permissions, times(2)).playerHas("synthetic-world", offline, "rewards.offline"); + } + } + + @Test void offlinePermissionLookupDoesNotReenterNativePlayerAfterReconnect() throws Exception { + try (Fixture fixture = new Fixture()) { + var api = mock(net.luckperms.api.LuckPerms.class, RETURNS_DEEP_STUBS); + var stored = mock(net.luckperms.api.model.user.User.class, RETURNS_DEEP_STUBS); + when(api.getUserManager().getUser(fixture.id)).thenReturn(stored); + when(stored.getCachedData().getPermissionData(api.getContextManager().getStaticQueryOptions()) + .checkPermission("rewards.offline")).thenReturn(net.luckperms.api.util.Tristate.TRUE); + var services = mock(org.bukkit.plugin.ServicesManager.class); + @SuppressWarnings("unchecked") + org.bukkit.plugin.RegisteredServiceProvider registration = + mock(org.bukkit.plugin.RegisteredServiceProvider.class); + when(registration.getProvider()).thenReturn(api); + when(services.getRegistration(net.luckperms.api.LuckPerms.class)).thenReturn(registration); + fixture.bukkit.when(Bukkit::getServicesManager).thenReturn(services); + var hook = new com.bencodez.advancedcore.api.permissions.LuckPermsHandle(); + hook.load(fixture.plugin); + when(fixture.plugin.getLuckPermsHandle()).thenReturn(hook); + doReturn(null).when(fixture.user).getPlayer(); + AtomicReference reconnected = new AtomicReference<>(); + fixture.bukkit.when(() -> Bukkit.getPlayer(fixture.id)).thenAnswer(call -> reconnected.get()); + fixture.bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + when(fixture.player.hasPermission("rewards.offline")).thenReturn(true); + AtomicInteger dispatches = new AtomicInteger(); + var scheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { + if (dispatches.incrementAndGet() == 2) reconnected.set(fixture.player); + boolean previous = fixture.playerOwner.get(); + fixture.playerOwner.set(false); + try { call.getArgument(1, Runnable.class).run(); } + finally { fixture.playerOwner.set(previous); } + return null; + }).when(scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementPermission.register(fixture.handler, fixture.plugin); + assertTrue(fixture.permissionReward("rewards.offline").canGiveRewardAsync(fixture.user, new RewardOptions()) + .toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertSame(fixture.player, reconnected.get(), "reconnect occurs before the provider worker"); + verify(fixture.player, never()).hasPermission(anyString()); + verify(stored.getCachedData().getPermissionData(api.getContextManager().getStaticQueryOptions())) + .checkPermission("rewards.offline"); + } + } + + @Test void synchronousOfflinePermissionRetainsItsPrimaryThreadGuard() { + try (Fixture fixture = new Fixture()) { + var api = mock(net.luckperms.api.LuckPerms.class, RETURNS_DEEP_STUBS); + var services = mock(org.bukkit.plugin.ServicesManager.class); + @SuppressWarnings("unchecked") + org.bukkit.plugin.RegisteredServiceProvider registration = + mock(org.bukkit.plugin.RegisteredServiceProvider.class); + when(registration.getProvider()).thenReturn(api); + when(services.getRegistration(net.luckperms.api.LuckPerms.class)).thenReturn(registration); + fixture.bukkit.when(Bukkit::getServicesManager).thenReturn(services); + var hook = new com.bencodez.advancedcore.api.permissions.LuckPermsHandle(); + hook.load(fixture.plugin); + when(fixture.plugin.getLuckPermsHandle()).thenReturn(hook); + fixture.bukkit.when(() -> Bukkit.getPlayer(fixture.id)).thenReturn(null); + fixture.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + fixture.bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + when(api.getUserManager().getUser(fixture.id)).thenReturn(null); + when(api.getUserManager().loadUser(fixture.id)).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + assertFalse(PlayerManager.getInstance().hasServerPermission(fixture.id, "TimedPlayer", "rewards.offline")); + verify(api.getUserManager(), never()).loadUser(any(UUID.class)); + } + } + + @Test void giveOfflineVaultGroupDoesNotDependOnRetiringPlayerScheduler() throws Exception { + try (Fixture fixture = new Fixture()) { + useWorkerContext(fixture); + var permissions = installVault(fixture); + var offline = mock(org.bukkit.OfflinePlayer.class); + doReturn(offline).when(fixture.user).getOfflinePlayer(); + when(permissions.getPrimaryGroup(null, offline)).thenReturn("member"); + var scheduler = fixture.plugin.getBukkitScheduler(); + // SimpleAPI omits the entity callback after the captured session retires. + doAnswer(call -> null).when(scheduler).executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + Reward reward = fixture.reward("Due", false); + reward.getConfig().getConfigData().set("VaultGroup", "member"); + clearInvocations(scheduler); // Exclude fixture login setup, retain all evaluated dispatches. + assertTrue(reward.canGiveRewardAsync(fixture.user, new RewardOptions().setGiveOffline(true)) + .toCompletableFuture().get(2, TimeUnit.SECONDS)); + verify(permissions).getPrimaryGroup(null, offline); + verify(scheduler, never()).executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + } + } + + @Test void negativePermissionDoesNotAuthorizeADisconnectedNativeSession() throws Exception { + try (Fixture fixture = new Fixture()) { + useWorkerContext(fixture); + when(fixture.player.isOnline()).thenReturn(false); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementPermission.register(fixture.handler, fixture.plugin); + assertFalse(fixture.permissionReward("!rewards.denied").canGiveRewardAsync(fixture.user, new RewardOptions()) + .toCompletableFuture().get(2, TimeUnit.SECONDS)); + verify(fixture.player, never()).hasPermission(anyString()); + } + } + + @Test void defaultPermissionAndDisabledRequirementKeepExistingSemantics() throws Exception { + try (Fixture fixture = new Fixture()) { + useWorkerContext(fixture); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementPermission.register(fixture.handler, fixture.plugin); + when(fixture.player.hasPermission("AdvancedCore.Reward.Due")).thenReturn(true); + assertTrue(fixture.permissionReward("").canGiveRewardAsync(fixture.user, new RewardOptions()) + .toCompletableFuture().get(2, TimeUnit.SECONDS)); + Reward disabled = fixture.permissionReward("rewards.disabled"); + disabled.getConfig().getConfigData().set("RequirePermission", false); + assertTrue(disabled.canGiveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture().get(2, TimeUnit.SECONDS)); + verify(fixture.player, never()).hasPermission("rewards.disabled"); + } + } + + private static void useWorkerContext(Fixture fixture) { + var scheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { + boolean previous = fixture.playerOwner.get(); + fixture.playerOwner.set(false); + try { call.getArgument(1, Runnable.class).run(); } + finally { fixture.playerOwner.set(previous); } + return null; + }).when(scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + } + + @SuppressWarnings("unchecked") + private static net.milkbowl.vault.permission.Permission installVault(Fixture fixture) { + var permissions = mock(net.milkbowl.vault.permission.Permission.class); + when(permissions.getGroups()).thenReturn(new String[] { "member" }); + var server = mock(org.bukkit.Server.class, RETURNS_DEEP_STUBS); + when(fixture.plugin.getServer()).thenReturn(server); + when(server.getPluginManager().getPlugin("Vault")).thenReturn(mock(org.bukkit.plugin.Plugin.class)); + org.bukkit.plugin.RegisteredServiceProvider registration = + mock(org.bukkit.plugin.RegisteredServiceProvider.class); + when(registration.getProvider()).thenReturn(permissions); + when(server.getServicesManager().getRegistration(net.milkbowl.vault.permission.Permission.class)).thenReturn(registration); + when(fixture.plugin.isLoadVault()).thenReturn(true); + var scheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { fixture.onOwner(call.getArgument(1)); return null; }) + .when(scheduler).runTaskLater(eq(fixture.plugin), any(Runnable.class), eq(5L)); + var vault = new com.bencodez.advancedcore.VaultHandler(); + when(fixture.plugin.getVaultHandler()).thenReturn(vault); + vault.loadVault(fixture.plugin); + return permissions; + } + + @Test void modeChangeDuringWorkerRequirementsKeepsTheTimedEligibilitySignal() { + try (Fixture fixture = new Fixture()) { + fixture.handler.addInjectedRequirements(new com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject("SyntheticTransition") { + @Override public boolean onRequirementRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, RewardOptions options) { + fixture.mode.set(GameMode.CREATIVE); + return true; + } + }); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementGameMode.register(fixture.handler, fixture.plugin); + Reward reward = fixture.reward("Due", true); + RewardOptions options = new RewardOptions().setCheckTimed(false); + options.setTimedQueueReplay(true); + var failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveRewardAsync(fixture.user, options).toCompletableFuture().join()); + assertTrue(Reward.isPlayerEligibilityDeferred(failure), + "a later native denial must retain the timed queue's eligibility retry classification"); + assertEquals(0, fixture.prefix.get()); + } + } + + @Test void whitelistedInlineWorldPreparesFilesOnlyOnTheWorker() throws Exception { + worldRequirementsPrepareFilesOnTheWorkerAndReadTheWorldOnTheOwner("Worlds"); + } + + @Test void blacklistedInlineWorldPreparesFilesOnlyOnTheWorker() throws Exception { + worldRequirementsPrepareFilesOnTheWorkerAndReadTheWorldOnTheOwner("BlackListedWorlds"); + } + + private void worldRequirementsPrepareFilesOnTheWorkerAndReadTheWorldOnTheOwner(String path) throws Exception { + try (Fixture fixture = new Fixture()) { + var config = fixture.plugin.getOptions(); + when(config.getDefaultRewardWorlds()).thenReturn(new ArrayList<>()); + when(config.getDefaultRewardBlackListedWorlds()).thenReturn(new ArrayList<>()); + var scheduler = fixture.plugin.getBukkitScheduler(); + doAnswer(call -> { + boolean previous = fixture.playerOwner.get(); + fixture.playerOwner.set(false); + try { call.getArgument(1, Runnable.class).run(); } + finally { fixture.playerOwner.set(previous); } + return null; + }).when(scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + org.bukkit.World world = mock(org.bukkit.World.class); + when(world.getName()).thenReturn("synthetic-world"); + when(fixture.player.getWorld()).thenAnswer(call -> { + assertTrue(fixture.playerOwner.get()); + return world; + }); + com.bencodez.advancedcore.api.rewards.builtin.requirements.RequirementWorld.register(fixture.handler, fixture.plugin); + Reward reward = spy(fixture.reward("Due", true)).needsRewardFile(true); + assertFalse(reward.getConfig().hasRewardFile(), "inline source stays section-backed after snapshot preparation"); + reward.getConfig().getConfigData().set(path, new ArrayList<>( + List.of(path.equals("Worlds") ? "synthetic-world" : "other-world"))); + doAnswer(call -> { + assertFalse(fixture.playerOwner.get(), "every generated-file preparation belongs on the worker"); + return null; + }).when(reward).checkRewardFile(); + var delivery = reward.giveRewardAsync(fixture.user, new RewardOptions()).toCompletableFuture(); + fixture.drain(); + delivery.get(2, TimeUnit.SECONDS); + verify(reward).checkRewardFile(); + verify(fixture.player).getWorld(); + } + } + + @Test void initiallyDeniedTimedRewardKeepsItsDueTimeAndBoundedRetry() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.mode.set(GameMode.CREATIVE); + long due = System.currentTimeMillis() - 10_000; + Reward reward = fixture.reward("Due", true); + fixture.user.addTimedReward(reward, new HashMap<>(), due); + String occurrence = occurrence(fixture.onlyTimedKey()); + fixture.user.checkDelayedTimedRewards(); + fixture.drain(); + assertEquals(due, fixture.user.getTimedRewards().get(fixture.onlyTimedKey())); + assertEquals(occurrence, occurrence(fixture.onlyTimedKey())); + assertEquals(0, fixture.prefix.get()); + assertEquals(1, fixture.scheduledMillis.size(), "eligibility must reuse the fallback instead of adding a retry timer"); + verify(fixture.delayed, times(1)).scheduleWithFixedDelay(any(Runnable.class), eq(10L), eq(10L), eq(TimeUnit.SECONDS)); + verify(fixture.logger, never()).warning(anyString()); + } + } + + @Test void repeatedDeniedSignalsAndMultipleDueEntriesShareOneFallback() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.mode.set(GameMode.CREATIVE); + Reward due = fixture.reward("Due", true); + Reward future = fixture.reward("Future", true); + long now = System.currentTimeMillis(); + fixture.user.addTimedReward(due, new HashMap<>(), now - 10_000); + fixture.user.addTimedReward(due, new HashMap<>(), now - 9_000); + fixture.user.addTimedReward(future, new HashMap<>(), now + TimeUnit.HOURS.toMillis(1)); + HashMap expected = timesByOccurrence(fixture.user.getTimedRewards()); + assertEquals(3, expected.size()); + fixture.user.checkDelayedTimedRewards(); + fixture.drain(); + for (int attempt = 0; attempt < 4; attempt++) { + fixture.handler.getRewardEligibility().signal(fixture.user).toCompletableFuture().get(2, TimeUnit.SECONDS); + fixture.drain(); + assertEquals(expected, timesByOccurrence(fixture.user.getTimedRewards())); + } + assertEquals(3, fixture.scheduledMillis.size(), "only the three original timers may be scheduled"); + verify(fixture.delayed, times(1)).scheduleWithFixedDelay(any(Runnable.class), eq(10L), eq(10L), eq(TimeUnit.SECONDS)); + assertEquals(0, fixture.prefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + verify(fixture.player, never()).giveExp(anyInt()); + assertTrue(fixture.stored("OfflineRewards").isEmpty()); + } + } + + @Test void fullHintBudgetStillWakesAnOrdinaryOfflineRewardAfterModeChange() throws Exception { + fullHintBudgetScenario(false); + } + + @Test void fullTimedHintBudgetStillWakesAnOrdinaryOfflineRewardAfterModeChange() throws Exception { + fullHintBudgetScenario(true); + } + + private void fullHintBudgetScenario(boolean protectedTimedHints) throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.handler.getRewardEligibility().signalLogin(fixture.user, fixture.player) + .toCompletableFuture().join(); + for (int i = 0; i < RewardEligibility.MAX_PENDING_PLAYERS; i++) { + AdvancedCoreUser retained = mock(AdvancedCoreUser.class, withSettings().stubOnly()); + when(retained.getUUID()).thenReturn("synthetic-retained-" + i); + if (protectedTimedHints) { + when(retained.getPlugin()).thenReturn(fixture.plugin); + assertTrue(fixture.handler.getRewardEligibility().retainTimedRetry(retained)); + } else EligibilityAccess.queued(fixture.handler.getRewardEligibility(), retained); + } + fixture.mode.set(GameMode.CREATIVE); + Reward reward = fixture.reward("Offline", true); + String id = UUID.randomUUID().toString(); + RewardOptions options = new RewardOptions().setCheckTimed(false).setGiveOffline(true); + options.setAsyncReplayOccurrenceId(id); + reward.giveRewardAsync(fixture.user, options).toCompletableFuture().join(); + fixture.drain(); + assertEquals(1, fixture.stored("OfflineRewards").size()); + assertTrue(fixture.stored("OfflineRewards").get(0).contains(id)); + verify(fixture.player, never()).giveExp(3); + fixture.mode.set(GameMode.SURVIVAL); + fixture.onOwner(() -> fixture.handler.getRewardEligibility().signal(fixture.player)); + fixture.drainQueuedWork(); + verify(fixture.player).giveExp(3); + assertEquals(1, fixture.offlinePrefix.get()); + assertTrue(fixture.stored("OfflineRewards").isEmpty()); + fixture.onOwner(() -> fixture.handler.getRewardEligibility().signal(fixture.player)); + fixture.drainQueuedWork(); + verify(fixture.player, times(1)).giveExp(3); + } + } + + @Test void closedFallbackRetainsFutureTimerInsteadOfStrandingDueWork() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.mode.set(GameMode.CREATIVE); + Reward due = fixture.reward("Due", true); + fixture.user.addTimedReward(due, new HashMap<>(), System.currentTimeMillis() - 10_000); + String originalOccurrence = occurrence(fixture.onlyTimedKey()); + fixture.handler.getRewardEligibility().close(); + fixture.user.checkDelayedTimedRewards(); + fixture.drain(); + assertEquals(originalOccurrence, occurrence(fixture.onlyTimedKey())); + assertTrue(fixture.user.getTimedRewards().get(fixture.onlyTimedKey()) > System.currentTimeMillis(), + "an unavailable bounded fallback requires a future durable retry deadline"); + assertEquals(2, fixture.scheduledMillis.size(), "one original timer and one ordinary retry must remain"); + verify(fixture.delayed, never()).scheduleWithFixedDelay(any(Runnable.class), anyLong(), anyLong(), any(TimeUnit.class)); + assertEquals(0, fixture.prefix.get()); + verify(fixture.player, never()).giveExp(anyInt()); + } + } + + @Test void acceptedAuthenticatedLoginResumesDueTimedWorkAfterQuit() throws Exception { + authenticatedLoginScenario(false); + } + + @Test void acceptedLoginRechecksTimedWorkAfterEarlierJoinReplayReleasesItsClaim() throws Exception { + replayClaimLoginScenario(false); + } + + @Test void quitWhileWaitingForClaimReleaseRequiresAnotherAcceptedLogin() throws Exception { + replayClaimLoginScenario(true); + } + + private void replayClaimLoginScenario(boolean quitBeforeRelease) throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.mode.set(GameMode.SURVIVAL); + Reward due = fixture.reward("Due", true); + fixture.user.addTimedReward(due, new HashMap<>(), System.currentTimeMillis() - 10_000); + RewardEligibilityListener listener = new RewardEligibilityListener(fixture.plugin); + fixture.onOwner(() -> listener.onNativeJoin(new org.bukkit.event.player.PlayerJoinEvent(fixture.player, ""))); + fixture.holdReplayCompletion.set(true); + fixture.user.checkDelayedTimedRewards(); + fixture.drainQueuedWork(); + assertNotNull(fixture.capturedReplayFailure.get(), "the real pending-login gate must defer delivery"); + assertTrue(Reward.isPlayerEligibilityDeferred(fixture.capturedReplayFailure.get())); + assertTrue(fixture.user.hasPendingRewardReplay(), "the original replay owns the occurrence until completion is delivered"); + + fixture.queueWorkers.set(true); + listener.onLogin(new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), fixture.player)); + listener.onLogin(new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), fixture.player)); + fixture.drainQueuedWork(); + verify(fixture.player, never()).giveExp(anyInt()); + if (quitBeforeRelease) fixture.onOwner(() -> listener.onQuit(new PlayerQuitEvent(fixture.player, ""))); + fixture.holdReplayCompletion.set(false); + fixture.heldReplayCompletion.completeExceptionally(fixture.capturedReplayFailure.get()); + fixture.drainQueuedWork(); + if (quitBeforeRelease) { + verify(fixture.player, never()).giveExp(anyInt()); + assertEquals(0, fixture.prefix.get()); + assertEquals(1, fixture.user.getTimedRewards().size()); + listener.onLogin(new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), fixture.player)); + fixture.drainQueuedWork(); + } + + verify(fixture.player).giveExp(3); + assertEquals(1, fixture.prefix.get()); + assertTrue(fixture.user.getTimedRewards().isEmpty()); + assertFalse(fixture.user.hasPendingRewardReplay()); + } + } + + @Test void staleAsyncLoginCannotWakeUnauthenticatedReplacementSession() throws Exception { + authenticatedLoginScenario(true); + } + + private void authenticatedLoginScenario(boolean staleEvent) throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.mode.set(GameMode.CREATIVE); + Reward due = fixture.reward("Due", true); + Reward future = fixture.reward("Future", true); + long dueTime = System.currentTimeMillis() - 10_000; + long futureTime = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); + fixture.user.addTimedReward(due, new HashMap<>(), dueTime); + String dueOccurrence = occurrence(fixture.onlyTimedKey()); + fixture.user.addTimedReward(future, new HashMap<>(), futureTime); + String futureKey = fixture.user.getTimedRewards().entrySet().stream() + .filter(entry -> entry.getValue() == futureTime).findFirst().orElseThrow().getKey(); + fixture.user.checkDelayedTimedRewards(); + fixture.drain(); + assertEquals(0, fixture.prefix.get()); + assertEquals(2, fixture.user.getTimedRewards().size()); + RewardEligibilityListener listener = new RewardEligibilityListener(fixture.plugin); + listener.onQuit(new PlayerQuitEvent(fixture.player, "")); + doReturn(false).when(fixture.player).isOnline(); + doReturn(null).when(fixture.user).getPlayer(); + doReturn(false).when(fixture.user).isOnline(); + java.lang.reflect.Field pending = RewardEligibility.class.getDeclaredField("pending"); + pending.setAccessible(true); + for (Object hint : ((java.util.Map) pending.get(fixture.handler.getRewardEligibility())).values()) { + java.lang.reflect.Field nativePlayer = hint.getClass().getDeclaredField("player"); + nativePlayer.setAccessible(true); + assertNull(nativePlayer.get(hint), "quit must release cached native player references"); + } + clearInvocations(fixture.data); + assertNotNull(fixture.fallbackSweep.get()); + fixture.fallbackSweep.get().run(); + fixture.drain(); + verifyNoInteractions(fixture.data); + Player replacement = mock(Player.class); + installPlayerMetadata(replacement); + when(replacement.getUniqueId()).thenReturn(fixture.id); + when(replacement.isOnline()).thenReturn(true); + when(replacement.getGameMode()).thenAnswer(call -> { + assertTrue(fixture.playerOwner.get(), "replacement gamemode reads require the owning scheduler"); + return GameMode.SURVIVAL; + }); + fixture.bukkit.when(() -> Bukkit.getPlayer(fixture.id)).thenReturn(replacement); + AdvancedCoreUser resumed = spy(new AdvancedCoreUser(fixture.plugin, fixture.id, "TimedPlayer")); + resumed.setData(fixture.data); + doReturn(replacement).when(resumed).getPlayer(); + doReturn(true).when(resumed).isOnline(); + if (staleEvent) { + // A cached logical user now resolves the new physical connection, + // but the queued authentication event still belongs to the quit player. + doReturn(replacement).when(fixture.user).getPlayer(); + doReturn(true).when(fixture.user).isOnline(); + clearInvocations(fixture.data); + dispatchLogin(listener, new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), fixture.player)); + fixture.drain(); + verify(replacement, never()).giveExp(anyInt()); + verify(fixture.player, never()).giveExp(anyInt()); + verifyNoInteractions(fixture.data); + assertEquals(0, fixture.prefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + assertEquals(dueTime, timesByOccurrence(fixture.user.getTimedRewards()).get(dueOccurrence)); + assertEquals(futureTime, fixture.user.getTimedRewards().get(futureKey)); + } + AdvancedCoreLoginEvent cancelled = new AdvancedCoreLoginEvent(resumed, resumed.getUUID(), replacement); + cancelled.setCancelled(true); + clearInvocations(fixture.data); + dispatchLogin(listener, cancelled); + fixture.drain(); + verifyNoInteractions(fixture.data); + verify(replacement, never()).giveExp(anyInt()); + assertEquals(0, fixture.prefix.get()); + assertEquals(dueTime, timesByOccurrence(fixture.user.getTimedRewards()).get(dueOccurrence)); + dispatchLogin(listener, new AdvancedCoreLoginEvent(resumed, resumed.getUUID(), replacement)); + fixture.drain(); + verify(replacement, times(1)).giveExp(3); + verify(fixture.player, never()).giveExp(anyInt()); + assertEquals(1, fixture.prefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + assertEquals(new HashMap<>(java.util.Map.of(futureKey, futureTime)), fixture.user.getTimedRewards()); + dispatchLogin(listener, new AdvancedCoreLoginEvent(resumed, resumed.getUUID(), replacement)); + fixture.drain(); + verify(replacement, times(1)).giveExp(3); + assertEquals(1, fixture.prefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + } + } + + @Test void quitAfterAcceptedLoginBeforeQueuedWorkerCannotRewardReplacement() throws Exception { + loginWorkerRaceScenario(false); + } + + @Test void quitDuringReplayOwnershipProbeCannotRewardReplacement() throws Exception { + loginWorkerRaceScenario(true); + } + + private void loginWorkerRaceScenario(boolean quitDuringProbe) throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.mode.set(GameMode.CREATIVE); + Reward due = fixture.reward("Due", true); + Reward future = fixture.reward("Future", true); + Reward offline = fixture.reward("Offline", true); + long dueTime = System.currentTimeMillis() - 10_000; + long futureTime = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); + fixture.user.addTimedReward(due, new HashMap<>(), dueTime); + String dueOccurrence = occurrence(fixture.onlyTimedKey()); + fixture.user.addTimedReward(future, new HashMap<>(), futureTime); + String futureKey = fixture.user.getTimedRewards().entrySet().stream() + .filter(entry -> entry.getValue() == futureTime).findFirst().orElseThrow().getKey(); + RewardOptions offlineOptions = new RewardOptions().setCheckTimed(false).setGiveOffline(true); + String offlineOccurrence = UUID.randomUUID().toString(); + offlineOptions.setAsyncReplayOccurrenceId(offlineOccurrence); + fixture.user.addOfflineRewards(offline, new HashMap<>(), offlineOptions); + fixture.user.checkDelayedTimedRewards(); + fixture.drain(); + assertEquals(0, fixture.prefix.get()); + AtomicReference current = new AtomicReference<>(fixture.player); + doAnswer(call -> current.get()).when(fixture.user).getPlayer(); + doAnswer(call -> current.get() != null).when(fixture.user).isOnline(); + doAnswer(call -> current.get() == fixture.player).when(fixture.player).isOnline(); + Player replacement = mock(Player.class); + installPlayerMetadata(replacement); + when(replacement.getUniqueId()).thenReturn(fixture.id); + when(replacement.isOnline()).thenAnswer(call -> current.get() == replacement); + when(replacement.getGameMode()).thenAnswer(call -> { + assertTrue(fixture.playerOwner.get(), "replacement mode reads require the owning scheduler"); + return GameMode.SURVIVAL; + }); + fixture.bukkit.when(() -> Bukkit.getPlayer(fixture.id)).thenAnswer(call -> current.get()); + RewardEligibilityListener listener = new RewardEligibilityListener(fixture.plugin); + fixture.mode.set(GameMode.SURVIVAL); + fixture.queueWorkers.set(true); + dispatchLogin(listener, new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), fixture.player)); + assertFalse(fixture.workers.isEmpty(), "accepted login must validate before handing replay to its worker"); + clearInvocations(fixture.data, fixture.user); + Runnable quitAndReplace = () -> { + fixture.onOwner(() -> listener.onQuit(new PlayerQuitEvent(fixture.player, ""))); + current.set(replacement); + }; + java.util.concurrent.atomic.AtomicBoolean quitFromProbe = new java.util.concurrent.atomic.AtomicBoolean(); + if (quitDuringProbe) { + java.util.concurrent.atomic.AtomicBoolean armed = new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call -> { + boolean claimed = (boolean) call.callRealMethod(); + if (armed.compareAndSet(true, false)) { + quitFromProbe.set(true); + quitAndReplace.run(); + } + return claimed; + }).when(fixture.user).hasPendingRewardReplay(); + } else quitAndReplace.run(); + fixture.drainQueuedWork(); + if (quitDuringProbe) assertTrue(quitFromProbe.get(), "quit must happen during the real ownership probe"); + verify(fixture.player, never()).giveExp(anyInt()); + verify(replacement, never()).giveExp(anyInt()); + assertEquals(0, fixture.prefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + assertEquals(0, fixture.offlinePrefix.get()); + if (!quitDuringProbe) { + verify(fixture.user, never()).hasPendingRewardReplay(); + verifyNoInteractions(fixture.data); + } + assertFalse(fixture.user.hasPendingRewardReplay()); + assertEquals(dueTime, timesByOccurrence(fixture.user.getTimedRewards()).get(dueOccurrence)); + assertEquals(futureTime, fixture.user.getTimedRewards().get(futureKey)); + assertEquals(1, fixture.stored("OfflineRewards").size()); + assertEquals(offlineOccurrence, occurrence(fixture.stored("OfflineRewards").get(0))); + dispatchLogin(listener, new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), replacement)); + fixture.drainQueuedWork(); + verify(replacement, times(2)).giveExp(3); + verify(fixture.player, never()).giveExp(anyInt()); + assertEquals(1, fixture.prefix.get()); + assertEquals(1, fixture.offlinePrefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + assertTrue(fixture.stored("OfflineRewards").isEmpty()); + assertEquals(new HashMap<>(java.util.Map.of(futureKey, futureTime)), fixture.user.getTimedRewards()); + dispatchLogin(listener, new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), replacement)); + fixture.drainQueuedWork(); + verify(replacement, times(2)).giveExp(3); + assertEquals(1, fixture.prefix.get()); + assertEquals(1, fixture.offlinePrefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + assertTrue(fixture.stored("OfflineRewards").isEmpty()); + } + } + + @Test void lateNativeRetirementWaitsForReplacementAcceptedLogin() throws Exception { + lateRetirementScenario(false); + } + + @Test void replacementLoginBeforeLateHintPublicationRemainsProvable() throws Exception { + lateRetirementScenario(true); + } + + private void lateRetirementScenario(boolean loginBeforePublication) throws Exception { + java.util.concurrent.atomic.AtomicLong nanos = new java.util.concurrent.atomic.AtomicLong(1); + try (Fixture fixture = new Fixture(nanos::get)) { + fixture.mode.set(GameMode.SURVIVAL); + AtomicReference current = new AtomicReference<>(fixture.player); + doAnswer(call -> current.get()).when(fixture.user).getPlayer(); + doAnswer(call -> current.get() != null).when(fixture.user).isOnline(); + doAnswer(call -> current.get() == fixture.player).when(fixture.player).isOnline(); + fixture.bukkit.when(() -> Bukkit.getPlayer(fixture.id)).thenAnswer(call -> current.get()); + RewardEligibilityListener listener = new RewardEligibilityListener(fixture.plugin); + clearInvocations(fixture.data); + dispatchLogin(listener, new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), fixture.player)); + verifyNoInteractions(fixture.data); + Reward due = fixture.reward("Due", true); + Reward future = fixture.reward("Future", true); + long dueTime = System.currentTimeMillis() - 10_000; + long futureTime = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); + fixture.user.addTimedReward(due, new HashMap<>(), dueTime); + String dueOccurrence = occurrence(fixture.onlyTimedKey()); + fixture.user.addTimedReward(future, new HashMap<>(), futureTime); + String futureKey = fixture.user.getTimedRewards().entrySet().stream() + .filter(entry -> entry.getValue() == futureTime).findFirst().orElseThrow().getKey(); + RetiringOwnerScheduler retiring = new RetiringOwnerScheduler(fixture.plugin, fixture.plugin.getBukkitScheduler(), + task -> fixture.callbacks.add(() -> fixture.onOwner(task)), fixture.workers::add); + retiring.retireNext(false); + fixture.user.checkDelayedTimedRewards(); + assertEquals(1, fixture.prefix.get()); + assertFalse(fixture.workers.isEmpty(), "the real captured native action must await its retirement callback"); + java.lang.reflect.Field pending = RewardEligibility.class.getDeclaredField("pending"); + pending.setAccessible(true); + assertTrue(((java.util.Map) pending.get(fixture.handler.getRewardEligibility())).isEmpty(), + "initially eligible work must reproduce quit before any wake hint exists"); + fixture.queueWorkers.set(true); + fixture.onOwner(() -> listener.onQuit(new PlayerQuitEvent(fixture.player, ""))); + assertTrue(fixture.nativeMetadata.values().stream().flatMap(List::stream) + .noneMatch(value -> value.getOwningPlugin() == fixture.plugin), "quit must clear this plugin's receipt"); + Player replacement = mock(Player.class); + HashMap> replacementMetadata = installPlayerMetadata(replacement); + when(replacement.getUniqueId()).thenReturn(fixture.id); + when(replacement.isOnline()).thenAnswer(call -> current.get() == replacement); + when(replacement.getGameMode()).thenAnswer(call -> { + assertTrue(fixture.playerOwner.get(), "replacement mode reads require the owning scheduler"); + return GameMode.SURVIVAL; + }); + current.set(replacement); + if (loginBeforePublication) { + clearInvocations(fixture.data); + dispatchLogin(listener, new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), replacement)); + assertFalse(fixture.workers.isEmpty(), "the original retirement callback is still pending"); + verifyNoInteractions(fixture.data); + verify(replacement).setMetadata(anyString(), argThat(value -> value.getOwningPlugin() == fixture.plugin + && Boolean.TRUE.equals(value.value()))); + } + fixture.drainQueuedWork(); + verify(fixture.player, never()).giveExp(anyInt()); + verify(replacement, never()).giveExp(anyInt()); + assertEquals(dueTime, timesByOccurrence(fixture.user.getTimedRewards()).get(dueOccurrence)); + assertEquals(1, fixture.prefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + clearInvocations(fixture.data); + nanos.addAndGet(TimeUnit.SECONDS.toNanos(10)); + assertNotNull(fixture.fallbackSweep.get()); + fixture.fallbackSweep.get().run(); + fixture.drainQueuedWork(); + if (!loginBeforePublication) { + verify(replacement, never()).giveExp(anyInt()); + verifyNoInteractions(fixture.data); + assertEquals(dueTime, timesByOccurrence(fixture.user.getTimedRewards()).get(dueOccurrence)); + assertEquals(futureTime, fixture.user.getTimedRewards().get(futureKey)); + assertEquals(1, fixture.prefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + dispatchLogin(listener, new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), replacement)); + fixture.drainQueuedWork(); + } + verify(replacement, times(1)).giveExp(3); + verify(fixture.player, never()).giveExp(anyInt()); + assertEquals(1, fixture.prefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + assertEquals(new HashMap<>(java.util.Map.of(futureKey, futureTime)), fixture.user.getTimedRewards()); + dispatchLogin(listener, new AdvancedCoreLoginEvent(fixture.user, fixture.user.getUUID(), replacement)); + fixture.drainQueuedWork(); + verify(replacement, times(1)).giveExp(3); + assertEquals(1, fixture.prefix.get()); + retiring.enqueueStaleOwnerCallback(); + fixture.drainQueuedWork(); + retiring.repeatRetirementSignal(); + verify(replacement, times(1)).giveExp(3); + assertEquals(1, fixture.prefix.get()); + fixture.onOwner(() -> listener.onQuit(new PlayerQuitEvent(replacement, ""))); + assertTrue(replacementMetadata.values().stream().flatMap(List::stream) + .noneMatch(value -> value.getOwningPlugin() == fixture.plugin)); + } + } + + private static HashMap> installPlayerMetadata(Player player) { + HashMap> metadata = new HashMap<>(); + when(player.getMetadata(anyString())).thenAnswer(call -> + new ArrayList<>(metadata.getOrDefault(call.getArgument(0), new ArrayList<>()))); + when(player.hasMetadata(anyString())).thenAnswer(call -> + !metadata.getOrDefault(call.getArgument(0), new ArrayList<>()).isEmpty()); + doAnswer(call -> { + String key = call.getArgument(0); + MetadataValue value = call.getArgument(1); + ArrayList values = metadata.computeIfAbsent(key, ignored -> new ArrayList<>()); + values.removeIf(existing -> existing.getOwningPlugin() == value.getOwningPlugin()); + values.add(value); + return null; + }).when(player).setMetadata(anyString(), any(MetadataValue.class)); + doAnswer(call -> { + String key = call.getArgument(0); + org.bukkit.plugin.Plugin owner = call.getArgument(1); + ArrayList values = metadata.get(key); + if (values != null) { + values.removeIf(existing -> existing.getOwningPlugin() == owner); + if (values.isEmpty()) metadata.remove(key); + } + return null; + }).when(player).removeMetadata(anyString(), any(org.bukkit.plugin.Plugin.class)); + return metadata; + } + + private static void dispatchLogin(RewardEligibilityListener listener, AdvancedCoreLoginEvent event) throws Exception { + // Match Bukkit's annotated registration shape while allowing the negative + // regression to run against the version that has no login handler yet. + for (java.lang.reflect.Method method : listener.getClass().getDeclaredMethods()) { + if (method.isAnnotationPresent(EventHandler.class) && method.getParameterCount() == 1 + && method.getParameterTypes()[0].isInstance(event)) method.invoke(listener, event); + } + } + + @Test void nativeModeTransitionWakesInitiallyDeniedDueTimedReward() throws Exception { + timedWakeScenario(false); + } + + @Test void nativeModeTransitionKeepsCompletedTimedPrefixAndOccurrence() throws Exception { + timedWakeScenario(true); + } + + @Test void ordinaryFailureRetainsFutureRetryDeadline() throws Exception { + try (Fixture fixture = new Fixture()) { + Reward reward = fixture.reward("Due", false); + fixture.failNative.set(true); + fixture.user.addTimedReward(reward, new HashMap<>(), System.currentTimeMillis() - 10_000); + fixture.user.checkDelayedTimedRewards(); + fixture.drain(); + assertEquals(1, fixture.attempts.get()); + String retryKey = fixture.onlyTimedKey(); + long retryTime = fixture.user.getTimedRewards().get(retryKey); + assertTrue(retryTime > System.currentTimeMillis(), "ordinary failures must retain their retry delay"); + fixture.user.checkDelayedTimedRewards(); + fixture.handler.getRewardEligibility().signal(fixture.user).toCompletableFuture().get(2, TimeUnit.SECONDS); + fixture.drain(); + assertEquals(1, fixture.attempts.get()); + assertEquals(retryTime, fixture.user.getTimedRewards().get(retryKey)); + assertEquals(2, fixture.scheduledMillis.size(), "ordinary failure keeps one original and one retry timer"); + verify(fixture.logger).warning(startsWith("Could not deliver queued timed reward")); + } + } + + private void timedWakeScenario(boolean completedPrefix) throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.mode.set(completedPrefix ? GameMode.SURVIVAL : GameMode.CREATIVE); + Reward due = fixture.reward("Due", true); + Reward future = fixture.reward("Future", true); + long dueTime = System.currentTimeMillis() - 10_000; + long futureTime = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); + fixture.user.addTimedReward(due, new HashMap<>(), dueTime); + String dueKey = fixture.onlyTimedKey(); + String dueOccurrence = occurrence(dueKey); + fixture.user.addTimedReward(future, new HashMap<>(), futureTime); + String futureKey = fixture.user.getTimedRewards().entrySet().stream() + .filter(entry -> entry.getValue() == futureTime).findFirst().orElseThrow().getKey(); + fixture.user.checkDelayedTimedRewards(); + if (completedPrefix) { + assertEquals(1, fixture.prefix.get()); + assertFalse(fixture.callbacks.isEmpty(), "the final native action must be queued"); + fixture.mode.set(GameMode.CREATIVE); + } + fixture.drain(); + assertEquals(completedPrefix ? 1 : 0, fixture.prefix.get()); + verify(fixture.player, never()).giveExp(3); + String retained = fixture.user.getTimedRewards().keySet().stream() + .filter(key -> occurrence(key).equals(dueOccurrence)).findFirst().orElseThrow(); + assertEquals(dueOccurrence, occurrence(retained)); + assertTrue(fixture.stored("OfflineRewards").isEmpty()); + fixture.mode.set(GameMode.SURVIVAL); + fixture.onOwner(() -> fixture.handler.getRewardEligibility().signal(fixture.player)); + fixture.drain(); + verify(fixture.player, times(1)).giveExp(3); + assertEquals(1, fixture.prefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + assertEquals(new HashMap<>(java.util.Map.of(futureKey, futureTime)), fixture.user.getTimedRewards()); + assertFalse(fixture.user.hasPendingRewardReplay()); + fixture.onOwner(() -> fixture.handler.getRewardEligibility().signal(fixture.player)); + fixture.drain(); + verify(fixture.player, times(1)).giveExp(3); + assertEquals(1, fixture.prefix.get()); + assertEquals(0, fixture.futurePrefix.get()); + } + } + + private static HashMap timesByOccurrence(HashMap timed) { + HashMap times = new HashMap<>(); + timed.forEach((entry, time) -> times.put(occurrence(entry), time)); + return times; + } + + private static String occurrence(String entry) { + return entry.split("%asyncoccurrence%", 2)[1].split("%", 2)[0]; + } + + private final class Fixture implements AutoCloseable { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final Logger logger = mock(Logger.class); + final UserData data = mock(UserData.class); + final HashMap> persisted = new HashMap<>(); + final Player player = mock(Player.class); + final HashMap> nativeMetadata = installPlayerMetadata(player); + final UUID id = UUID.randomUUID(); + final AtomicReference mode = new AtomicReference<>(GameMode.SURVIVAL); + final java.util.concurrent.atomic.AtomicBoolean failNative = new java.util.concurrent.atomic.AtomicBoolean(); + final AtomicInteger prefix = new AtomicInteger(), futurePrefix = new AtomicInteger(), + offlinePrefix = new AtomicInteger(), attempts = new AtomicInteger(); + final BlockingQueue callbacks = new LinkedBlockingQueue<>(); + final BlockingQueue workers = new LinkedBlockingQueue<>(); + final java.util.concurrent.atomic.AtomicBoolean queueWorkers = new java.util.concurrent.atomic.AtomicBoolean(); + final ThreadLocal playerOwner = ThreadLocal.withInitial(() -> false); + final List scheduledMillis = new ArrayList<>(); + final java.util.concurrent.atomic.AtomicBoolean holdReplayCompletion = new java.util.concurrent.atomic.AtomicBoolean(); + final java.util.concurrent.CompletableFuture heldReplayCompletion = new java.util.concurrent.CompletableFuture<>(); + final AtomicReference capturedReplayFailure = new AtomicReference<>(); + final AtomicReference fallbackSweep = new AtomicReference<>(); + final ScheduledExecutorService delayed = mock(ScheduledExecutorService.class); + final org.mockito.MockedStatic bukkit = mockStatic(Bukkit.class); + final AdvancedCorePlugin previousPlayerManagerPlugin = PlayerManager.getInstance().getPlugin(); + final RewardHandler handler; + final AdvancedCoreUser user; + RewardEligibility customEligibility; + + Fixture() { this(null); } + + Fixture(java.util.function.LongSupplier nanoClock) { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getDataFolder()).thenReturn(directory); + when(plugin.getLogger()).thenReturn(logger); + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isOnlineMode()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(mock(UserDataManager.class)); + when(users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + when(data.getStringList(anyString(), eq(UserDataFetchMode.DEFAULT))) + .thenAnswer(call -> new ArrayList<>(stored(call.getArgument(0)))); + doAnswer(call -> { persisted.put(call.getArgument(0), new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(anyString(), any()); + doAnswer(call -> { persisted.put(call.getArgument(0), new ArrayList<>(call.getArgument(1))); return null; }) + .when(data).setStringList(anyString(), any(), eq(false)); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { onOwner(call.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + doAnswer(call -> { Runnable task = call.getArgument(1); callbacks.add(() -> onOwner(task)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Player.class)); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { + Runnable task = call.getArgument(0); + if (queueWorkers.get()) workers.add(task); + else task.run(); + return null; + }).when(timer).execute(any()); + when(delayed.schedule(any(Runnable.class), anyLong(), eq(TimeUnit.MILLISECONDS))).thenAnswer(call -> { + scheduledMillis.add(call.getArgument(1)); + return mock(ScheduledFuture.class); + }); + when(delayed.scheduleWithFixedDelay(any(Runnable.class), anyLong(), anyLong(), eq(TimeUnit.SECONDS))) + .thenAnswer(call -> { + fallbackSweep.set(call.getArgument(0)); + return mock(ScheduledFuture.class); + }); + when(player.getUniqueId()).thenReturn(id); + when(player.getName()).thenReturn("TimedPlayer"); + when(player.isOnline()).thenReturn(true); + when(player.getGameMode()).thenAnswer(call -> { + assertTrue(playerOwner.get(), "gamemode reads require the owning scheduler"); + return mode.get(); + }); + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + AdvancedCorePlugin.setInstance(plugin); + PlayerManager.getInstance().setPlugin(plugin); + handler = new RewardHandler(plugin) { + @Override public ScheduledExecutorService getDelayedTimer() { return delayed; } + @Override public java.util.concurrent.CompletionStage givePersistedQueueRewardAsync( + AdvancedCoreUser target, PersistedQueueReference reference, RewardOptions options) { + var actual = super.givePersistedQueueRewardAsync(target, reference, options); + if (!holdReplayCompletion.get()) return actual; + actual.whenComplete((ignored, failure) -> capturedReplayFailure.set(failure)); + return heldReplayCompletion; + } + @Override public RewardEligibility getRewardEligibility() { + return customEligibility == null ? super.getRewardEligibility() : customEligibility; + } + }; + if (nanoClock != null) { + handler.getRewardEligibility().close(); + customEligibility = EligibilityAccess.create(plugin, handler, nanoClock); + } + when(plugin.getRewardHandler()).thenReturn(handler); + user = spy(new AdvancedCoreUser(plugin, id, "TimedPlayer")); + user.setData(data); + doReturn(player).when(user).getPlayer(); + doReturn(true).when(user).isOnline(); + handler.getRewardEligibility().signalLogin(user, player).toCompletableFuture().join(); + doAnswer(call -> { + call.getArgument(1, java.util.function.Consumer.class).accept(user); + return null; + }).when(users).getUserAsync(eq("TimedPlayer"), any(java.util.function.Consumer.class), + any(java.util.function.Consumer.class)); + handler.addInjectedReward(new RewardInject("Prefix") { + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser target, + ConfigurationSection ignoredData, HashMap placeholders) { + if (reward.getName().equals("Due")) prefix.incrementAndGet(); + else if (reward.getName().equals("Offline")) offlinePrefix.incrementAndGet(); + else futurePrefix.incrementAndGet(); + return null; + } + }); + handler.addInjectedReward(new RewardInject("Native") { + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser target, + ConfigurationSection ignoredData, HashMap placeholders) { + attempts.incrementAndGet(); + if (failNative.get()) throw new IllegalStateException("Synthetic ordinary native failure"); + target.giveExp(3); + return null; + } + }); + } + + Reward reward(String name, boolean policy) { + YamlConfiguration definition = new YamlConfiguration(); + if (policy) definition.set("GameModes", List.of("SURVIVAL")); + Reward reward = new Reward(name, definition).needsRewardFile(false); + EligibilityAccess.bind(reward, plugin); + EligibilityAccess.bind(reward.getConfig(), plugin); + handler.getRewards().add(reward); + return reward; + } + + Reward permissionReward(String permission) { + Reward reward = reward("Due", true); + reward.getConfig().getConfigData().set("RequirePermission", true); + reward.getConfig().getConfigData().set("Permission", permission); + return reward; + } + + ArrayList stored(String path) { return persisted.getOrDefault(path, new ArrayList<>()); } + String onlyTimedKey() { + assertEquals(1, user.getTimedRewards().size()); + return user.getTimedRewards().keySet().iterator().next(); + } + void onOwner(Runnable task) { + boolean previous = playerOwner.get(); + playerOwner.set(true); + try { task.run(); } finally { playerOwner.set(previous); } + } + void drainQueuedWork() { + for (int attempt = 0; attempt < 100 && (!workers.isEmpty() || !callbacks.isEmpty()); attempt++) { + Runnable worker = workers.poll(); + if (worker != null) { + boolean previous = playerOwner.get(); + playerOwner.set(false); + try { worker.run(); } finally { playerOwner.set(previous); } + } else callbacks.remove().run(); + } + assertTrue(workers.isEmpty()); + assertTrue(callbacks.isEmpty()); + } + void drain() throws Exception { + for (int attempt = 0; attempt < 30 && !callbacks.isEmpty(); attempt++) callbacks.remove().run(); + assertTrue(callbacks.isEmpty()); + assertFalse(user.hasPendingRewardReplay(), "queued replay must release its claims after completion"); + } + @Override public void close() { + PlayerManager.getInstance().setPlugin(previousPlayerManagerPlugin); + handler.getRewardEligibility().close(); + delayed.shutdownNow(); + AdvancedCorePlugin.setInstance(null); + bukkit.close(); + } + } +} 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 247d4b9502..c50bc4840c 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 @@ -26,6 +26,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.ConcurrentHashMap; @@ -45,6 +46,7 @@ import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.data.ServerData; import com.bencodez.simpleapi.scheduler.BukkitScheduler; @@ -154,6 +156,98 @@ public void giveItemAsyncCompletesOnlyAfterOwnedInventoryDelivery() { verify(inventory).addItem(item); } + @Test + public void eligibilityLossBeforeDeliveryDoesNotMutateInventoryOrOverflow() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + World world = mock(World.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(player.getWorld()).thenReturn(world); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(true); + AtomicBoolean eligible = new AtomicBoolean(true); + CompletionStage delivery = fixture.handler.giveItemWhenEligibleAsync(player, eligible::get, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + eligible.set(false); + 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)); + assertEquals(Reward.playerEligibilityDeferred().getClass(), failure.getCause().getCause().getClass()); + verify(inventory, never()).addItem(any(ItemStack[].class)); + verify(world, never()).dropItem(any(Location.class), any(ItemStack.class)); + verify(player, never()).updateInventory(); + assertTrue(fixture.handler.getItems().isEmpty()); + } + + @Test + public void oldAsyncItemVarargsStillAcceptNullFirstItem() 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(null, item)).thenReturn(new HashMap<>()); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, null, 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); + verify(inventory).addItem(null, item); + } + + @Test + public void committedOverflowRemainsDeliverableAfterEligibilityChanges() 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))); + when(inventory.addItem(excess)).thenReturn(new HashMap<>()); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + AtomicBoolean eligible = new AtomicBoolean(true); + AtomicInteger eligibilityChecks = new AtomicInteger(); + CompletionStage delivery = fixture.handler.giveItemWhenEligibleAsync(player, + () -> { eligibilityChecks.incrementAndGet(); return eligible.get(); }, item); + ArgumentCaptor tasks = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), tasks.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + tasks.getValue().run(); + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + assertEquals(List.of(excess), fixture.handler.getItems().get(uuid)); + eligible.set(false); + fixture.handler.check(player); + verify(fixture.bukkitScheduler, times(2)).runTask(eq(fixture.plugin), tasks.capture(), eq(player)); + tasks.getValue().run(); + } + assertEquals(1, eligibilityChecks.get()); + verify(inventory, times(1)).addItem(item); + verify(inventory, times(1)).addItem(excess); + assertTrue(fixture.handler.getItems().isEmpty()); + } + @Test public void noOverflowCompletionPrecedesShutdownSnapshot() throws Exception { Fixture fixture = createFixture(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/listeners/RewardGamemodeListenerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/listeners/RewardGamemodeListenerTest.java new file mode 100644 index 0000000000..8a35e10e08 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/listeners/RewardGamemodeListenerTest.java @@ -0,0 +1,68 @@ +package com.bencodez.advancedcore.tests.listeners; + +import com.bencodez.advancedcore.api.rewards.*; +import com.bencodez.advancedcore.listeners.*; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import org.bukkit.GameMode; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerGameModeChangeEvent; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.RewardEligibility; +import com.bencodez.advancedcore.api.rewards.RewardHandler; + +class RewardGamemodeListenerTest { + @Test + void cancelledChangeIsIgnoredByTheEventContract() throws Exception { + var annotation = RewardEligibilityListener.class.getDeclaredMethod("onGameModeChange", + PlayerGameModeChangeEvent.class).getAnnotation(org.bukkit.event.EventHandler.class); + org.junit.jupiter.api.Assertions.assertTrue(annotation.ignoreCancelled()); + } + + @Test + void cancelledChangeDoesNotScheduleAnotherCheck() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isEnabled()).thenReturn(true); + var event = new PlayerGameModeChangeEvent(mock(Player.class), GameMode.CREATIVE); + event.setCancelled(true); + new RewardEligibilityListener(plugin).onGameModeChange(event); + verify(plugin, never()).getBukkitScheduler(); + verify(plugin, never()).getRewardHandler(); + } + + @Test + void transitionWakeupRunsAfterCommitOnPlayerScheduler() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isEnabled()).thenReturn(true); + RewardHandler handler = mock(RewardHandler.class); + when(plugin.getRewardHandler()).thenReturn(handler); + RewardEligibility eligibility = mock(RewardEligibility.class); + when(handler.getRewardEligibility()).thenReturn(eligibility); + Player player = mock(Player.class); + when(player.isOnline()).thenReturn(true); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + RewardEligibilityListener listener = new RewardEligibilityListener(plugin); + listener.onGameModeChange(new PlayerGameModeChangeEvent(player, GameMode.CREATIVE)); + var callback = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskLater(eq(plugin), callback.capture(), eq(1L), eq(player)); + verifyNoInteractions(eligibility); + callback.getValue().run(); + verify(eligibility).signal(player); + } + @Test + void cancelledResolvedLoginDoesNotWakeQueues() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isEnabled()).thenReturn(true); + var event = new AdvancedCoreLoginEvent(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class), + "synthetic-logical-user", mock(Player.class)); + event.setCancelled(true); + new RewardEligibilityListener(plugin).onLogin(event); + verify(plugin, never()).getRewardHandler(); + } + +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRequirementRegistrationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRequirementRegistrationTest.java index 620246e9a1..c50c2c5397 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRequirementRegistrationTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRequirementRegistrationTest.java @@ -108,7 +108,15 @@ public void worldRegistersAllowedAndBlacklistedForms() { public void builtinCatalogRegistersAllRequirementPathsInHistoricalOrder() { withMockedServer(() -> assertRequirementPaths(BuiltinRequirements::register, "Chance", "RewardExpiration", "Permission", "DayOfMonth", "Server", "BlockedServers", "Worlds", - "BlackListedWorlds", "RewardType", "JavascriptExpression", "Date", "LocationDistance")); + "BlackListedWorlds", "GameModes", "RewardType", "JavascriptExpression", "Date", "LocationDistance")); + } + + @Test + public void onlyNativePredicatesRequestThePlayerOwner() { + withMockedServer(() -> BuiltinRequirements.register(rewardHandler, plugin)); + assertEquals(List.of("Worlds", "BlackListedWorlds", "GameModes", "LocationDistance"), + rewardHandler.getInjectedRequirements().stream().filter(RequirementInject::requiresPlayerThread) + .map(RequirementInject::getPath).toList()); } private void assertRequirementPaths(BiConsumer registrar, 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 index 45e3e4b066..88f295549b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java @@ -111,6 +111,10 @@ void setUp() { invocation.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); when(plugin.getBukkitScheduler()).thenReturn(scheduler); AdvancedCorePlugin.setInstance(plugin); try { @@ -784,6 +788,9 @@ void directAsyncDispatchLeavesBukkitPrimaryThreadBeforeFiringEvent() { when(user.getPlayer()).thenReturn(mock(Player.class)); org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); ArgumentCaptor asyncTask = ArgumentCaptor.forClass(Runnable.class); + java.util.Queue workers = new java.util.concurrent.ConcurrentLinkedQueue<>(); + doAnswer(invocation -> { workers.add(invocation.getArgument(1)); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); @@ -794,14 +801,17 @@ void directAsyncDispatchLeavesBukkitPrimaryThreadBeforeFiringEvent() { verify(scheduler).runTaskAsynchronously(eq(plugin), asyncTask.capture()); verify(pluginManager, never()).callEvent(any()); assertFalse(result.toCompletableFuture().isDone()); - asyncTask.getValue().run(); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + for (int count = 0; count < 100 && !workers.isEmpty(); count++) workers.remove().run(); + assertTrue(workers.isEmpty()); + assertTrue(result.toCompletableFuture().isDone(), "all queued worker continuations must complete"); result.toCompletableFuture().join(); verify(pluginManager).callEvent(any()); } } @Test - void asyncRewardRequirementsRunOnPlayerOwnerScheduler() { + void nativeAsyncRewardRequirementsRunOnPlayerOwnerScheduler() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); when(config.isProcessRewards()).thenReturn(true); when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); @@ -811,6 +821,7 @@ void asyncRewardRequirementsRunOnPlayerOwnerScheduler() { when(user.isOnline()).thenReturn(true); AtomicReference requirementThread = new AtomicReference<>(); handler.getInjectedRequirements().add(new RequirementInject("OwnerThread") { + @Override public boolean requiresPlayerThread() { return true; } @Override public boolean onRequirementRequest(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, RewardOptions ignoredOptions) { @@ -819,6 +830,9 @@ public boolean onRequirementRequest(Reward ignored, AdvancedCoreUser ignoredUser } }); ArgumentCaptor asyncTask = ArgumentCaptor.forClass(Runnable.class); + java.util.Queue workers = new java.util.concurrent.ConcurrentLinkedQueue<>(); + doAnswer(invocation -> { workers.add(invocation.getArgument(1)); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); doAnswer(invocation -> { Thread ownerThread = new Thread(invocation.getArgument(1, Runnable.class), "player-owner-thread"); ownerThread.start(); @@ -833,7 +847,10 @@ public boolean onRequirementRequest(Reward ignored, AdvancedCoreUser ignoredUser CompletionStage result = reward.giveRewardAsync(user, new RewardOptions().setCheckTimed(false)); verify(scheduler).runTaskAsynchronously(eq(plugin), asyncTask.capture()); - asyncTask.getValue().run(); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + for (int count = 0; count < 100 && !workers.isEmpty(); count++) workers.remove().run(); + assertTrue(workers.isEmpty()); + assertTrue(result.toCompletableFuture().isDone(), "all queued worker continuations must complete"); result.toCompletableFuture().join(); } diff --git a/docs/reward-gamemode-eligibility.md b/docs/reward-gamemode-eligibility.md new file mode 100644 index 0000000000..9b0aa28d1d --- /dev/null +++ b/docs/reward-gamemode-eligibility.md @@ -0,0 +1,166 @@ +# Reward gamemode eligibility + +Rewards may opt into the live player's Bukkit gamemode with `GameModes` and +`BlackListedGameModes`. Both values must be YAML lists containing at most four valid Bukkit +`GameMode` enum names (case is ignored). + +An allowlist requires the current mode to be present. A denylist rejects the +current mode, and denial wins when a mode appears in both lists. Omitting both +keys preserves existing reward behavior when no global default is configured. +Invalid types or names fail closed. + +The embedding plugin can set `DefaultRewardGameModes` in its primary configuration +(the same configuration that supplies `DefaultRewardWorlds`). For example: + +```yaml +DefaultRewardGameModes: + - SURVIVAL +``` + +The default is an empty list, so existing configurations keep their behavior. +A reward's explicit `GameModes` list overrides the global allowlist, including +`GameModes: []` to opt out. `BlackListedGameModes` still rejects matching modes. +The global list has the same four-entry limit and valid names as a reward list; +a malformed default denies rewards that inherit it, while an explicit valid +reward allowlist remains independent. Saved changes take effect through the +embedding plugin's normal configuration reload. AdvancedCore does not regenerate +or rewrite existing reward definitions to apply the default. + +```yaml +GameModes: + - SURVIVAL + - ADVENTURE +BlackListedGameModes: + - ADVENTURE +Commands: + - "give %player% iron_ingot 1" +``` + +The current mode is read from the online player on that player's owning server +scheduler. Rewards deferred by an unmet reattemptable requirement are retried +through the existing offline/timed queue after a completed gamemode transition; +the queue remains the source of truth. The transition wakeup uses a one-second +player-owned delay so it observes the committed mode, and reads it again before +remaining effects. Completed effects keep their existing replay checkpoints; this +does not promise atomicity between external commands and storage after a crash. +An already-passed chance is retained with the original deferred occurrence even +when eligibility changes before its first effect. Replay does not roll it again, +including after handler replacement or removal of a global default. + +Native transition signals recheck due timed rewards as well as the offline queue. +A timed occurrence deferred specifically by player eligibility retains its original +due time and replay identity while the bounded eligibility fallback owns its automatic +wake-up. Repeated deferrals do not create additional per-occurrence timer chains. +If that fallback cannot accept a hint, the occurrence keeps the ordinary future +retry deadline and timer instead. A transition does not accelerate unrelated future +rewards or ordinary failures with a future retry deadline. Expected eligibility deferrals +use debug logging; actual delivery failures retain their warning. + +The bounded fallback begins after ten seconds and backs off repeated attempts per +player up to five minutes, using elapsed monotonic time. Explicit eligibility +signals retry immediately and reset that fallback delay. Repeatedly retaining the +same unfinished occurrence does not reset the backoff or discard its wake-up hint. +Quit releases the cached native player reference but retains the bounded logical +hint. It does not poll that player's storage while waiting for login. The existing +resolved AdvancedCore login event, after configured authentication delays, rebinds +and wakes that known work after validating the exact live player session on its +owning scheduler. Cancelled events and stale events for a disconnected or replaced +session do not wake it. A quit also invalidates a queued eligibility check before +its worker starts. If replay already started, its ordinary per-action eligibility +checks continue honoring that login wait, retaining unfinished work for the accepted +replacement session. An accepted login also records a plugin-owned Boolean receipt +on that exact native player, even when there is no pending hint yet. A late retirement +can therefore recover work after login without treating a merely connected replacement +as authenticated. Unknown hints resolve and check this receipt on the player's owner +before reading reward queues. Other plugins' metadata and non-Boolean values are not +accepted. Quit removes only this plugin's receipt; it is session metadata, not persisted +player data or a new reward queue. Plugin startup continues reconstructing timed callbacks +from the existing durable queue. + +When the plugin is freshly enabled while players are already connected, it also +initializes receipts for those existing native sessions after its integrations +are ready. It uses the configured authentication providers, resolves the existing +user asynchronously by name (including offline-mode identity rules), and rechecks +the exact session on its owning scheduler. Newly joining sessions still use the +normal accepted-login path; this bootstrap does not emit another generic login +event or create another reward occurrence. Checks are sequential. A timed-out +provider query cannot accumulate more queries while its underlying operation is +still running. Provider failures fail closed and produce a bounded debug notice; +a later normal authenticated login remains the recovery path for a session whose +startup check could not complete. Disconnect, handler replacement and disable +invalidate pending receipt publication. + +Consumers that explicitly disable AdvancedCore user-data loading do not register +its normal login listener. For those consumers, native join and startup accept +the connected session directly on its owning scheduler, without loading a user, +querying authentication integrations, or firing an AdvancedCore login event. +Reconnect wakes only already-known deferred work and retains its original replay +identity. Consumers with user-data loading enabled keep the ordinary authenticated +login requirement described above. + +The retained-hint table is limited to 4,096 logical users. If it fills, ordinary +offline work is still discovered automatically: the existing fallback takes a +snapshot of currently online players and visits at most 128 per pass, using each +player's owning scheduler and asynchronous name-based user resolution. One +snapshot is finished before taking another, so a changing online population cannot +repeatedly displace an unvisited member of that cycle. Discovery backs off from ten +seconds to at most five minutes between passes and remains available until disable. +It scans online sessions, not the stored player database. Mode/world/login signals +also try immediate discovery. At most 128 overflow resolutions and 128 temporary +replay admissions may be outstanding; saturated admissions remain discoverable in +a later pass. An uncompleted storage or replay callback still occupies its slot, +rather than allowing timed-out requests to accumulate. Temporary admissions retain +the session gate while replay claims run; after saturation, configured gamemode +actions also require the plugin's accepted-login receipt. An offline or merely +connected replacement cannot acquire rewards through overflow recovery. The existing +persistent queue, occurrence identities and completed-effect checkpoints remain +authoritative; this is neither a second reward queue nor another delivery path. + +The existing `GiveOffline` policy still controls whether a denied ordinary reward +is retained. Forced or ignored requirements keep their established bypass behavior. +No temporary-inventory detection or integration is included in this change. + +Command lists also recheck before each pending command. A command that changes +mode is a completed effect; later commands wait for eligibility rather than +repeating that command. On Folia, console commands and economy mutations run on the global scheduler, +so a player-owned eligibility check and subsequent global handoff cannot be +atomic with a simultaneous gamemode change. Already-started effects are never +rolled back. + +For opted-in rewards on Folia, entity-scheduler retirement before an action +starts retains the unfinished work under the existing `GiveOffline` policy. +Immediate retirement and retirement after scheduling use the same action claim; +a stale callback cannot later execute that retained attempt. Scheduler rejection, +unknown timeouts, shutdown and already-committed inventory overflow retain their +existing failure and ownership behavior. Ordinary Spigot scheduling is unchanged. + +## Requirement execution context + +Async reward delivery and `canGiveRewardAsync` retain ordered requirement evaluation. +Scripts, custom requirements and preprocessing placeholder providers run on the reward +worker. Only native world, location and gamemode predicates request the player's owner; +world-requirement file preparation happens on the worker before that handoff. Custom +`RequirementInject` implementations may override `requiresPlayerThread()` for a short, +nonblocking native predicate and `prepareAsyncRequest(...)` for its worker preparation. +The synchronous `canGiveReward` method retains its existing caller-owned execution contract; +callers that need scheduling should use the asynchronous method. This does not make arbitrary +Bukkit calls inside administrator scripts safe on a worker. + +A freshly deferred hint never authenticates a player. Without this plugin's accepted-login +receipt it waits for the normal accepted-login signal before either offline or timed queue +replay. A native pending-login receipt also denies configured gamemode actions, even when +the current mode otherwise matches. Existing queue payloads and replay identities are retained. + +Asynchronous reward requirements keep online `Permission` and `VaultGroup` checks +on the resolved player's owning scheduler. Offline permission and group providers +remain on the reward worker, including `VaultGroup` when `GiveOffline` is enabled. +Permission negation, default permission names and requirement enablement keep their +existing meanings. The synchronous requirement API retains its caller-owned threading +contract. Custom requirements may override `onRequirementRequestAsync` to compose +native reads through `evaluatePlayerRequirement` with provider work through +`evaluateAsyncRequirement`; the default delegates to the existing synchronous callback. +These phase helpers await the result and do not create another reward occurrence. + +For rewards opting into either gamemode key, the native eligibility observation runs on the player owner, then injection callbacks (including `Javascripts`) return to the existing asynchronous reward scheduler. Collected player actions still return to their owner and recheck eligibility before mutation. Unconfigured awaited rewards retain their established callback boundary. Custom callbacks and scripts must continue to use supported scheduled APIs for direct Bukkit work; arbitrary script code is not made thread-safe automatically. + +For rewards using gamemode eligibility, the native player session resolved before an injection is retained across the worker callback. Collected actions and mixed command sections revalidate that session on its owning scheduler; a reconnect does not silently transfer an admitted action to a replacement session. Deferred work still follows the existing retry policy.