From bd877956084adcda97dd5e2f7f5b046e42ac6eda Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:35:23 -0600 Subject: [PATCH 1/9] Add optional gamemode reward eligibility --- .../advancedcore/AdvancedCorePlugin.java | 1 + .../api/item/FullInventoryHandler.java | 41 +- .../advancedcore/api/misc/MiscUtils.java | 52 ++- .../advancedcore/api/rewards/Reward.java | 169 +++++++- .../api/rewards/RewardEligibility.java | 285 ++++++++++++ .../api/rewards/RewardHandler.java | 4 + .../api/rewards/builtin/RewardCommands.java | 14 +- .../requirements/BuiltinRequirements.java | 1 + .../requirements/RequirementGameMode.java | 41 ++ .../api/user/AdvancedCoreUser.java | 264 +++++++++++- .../listeners/RewardEligibilityListener.java | 38 ++ .../api/rewards/RetiringOwnerScheduler.java | 100 +++++ .../api/rewards/RewardEligibilityTest.java | 119 +++++ .../rewards/RewardGamemodeCommandTest.java | 272 ++++++++++++ .../rewards/RewardGamemodeEconomyTest.java | 315 ++++++++++++++ .../rewards/RewardGamemodeMutationTest.java | 240 +++++++++++ .../api/rewards/RewardGamemodeReplayTest.java | 407 ++++++++++++++++++ .../listeners/RewardGamemodeListenerTest.java | 54 +++ .../tests/item/FullInventoryHandlerTest.java | 94 ++++ .../BuiltinRequirementRegistrationTest.java | 2 +- docs/reward-gamemode-eligibility.md | 45 ++ 21 files changed, 2512 insertions(+), 46 deletions(-) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementGameMode.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RetiringOwnerScheduler.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeReplayTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/RewardGamemodeListenerTest.java create mode 100644 docs/reward-gamemode-eligibility.md diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index 64a1fbe47a..ce42b0bcda 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) { 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/rewards/Reward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java index d3fa202130..0fab6f25b8 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,13 @@ public boolean canGiveReward(AdvancedCoreUser user, RewardOptions options) { return true; } + /** Evaluates requirements on the player's owning scheduler without blocking the caller. */ + public CompletionStage canGiveRewardAsync(AdvancedCoreUser user, RewardOptions options) { + RewardOptions stableOptions = options == null ? new RewardOptions() : options.copyForDispatch(); + return requestOnServerThread(user, + () -> CompletableFuture.completedFuture(canGiveReward(user, stableOptions))); + } + public boolean checkDelayed(AdvancedCoreUser user, HashMap placeholders) { if (!isDelayEnabled()) { return false; @@ -216,6 +225,13 @@ public String getRewardName() { } public void giveInjectedRewards(AdvancedCoreUser user, HashMap placeholders) { + if (RewardEligibility.isConfigured(getConfig().getConfigData())) { + giveRewardUserAsync(user, placeholders, new RewardOptions().setCheckTimed(false)).exceptionally(failure -> { + logRewardUserFailure(failure); + return null; + }); + return; + } ArrayList postReward = new ArrayList<>(); @@ -284,6 +300,9 @@ public void giveInjectedRewards(AdvancedCoreUser user, HashMap p */ public CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, HashMap placeholders) { + if (RewardEligibility.isConfigured(getConfig().getConfigData())) { + return giveRewardUserAsync(user, placeholders, new RewardOptions().setCheckTimed(false)); + } return giveInjectedRewardsAsync(user, placeholders, 0, new ReplayState(null), getRewardName(), UUID.randomUUID().toString()); } @@ -304,6 +323,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 +354,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 +625,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 +1003,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 +1171,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 +1238,19 @@ 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(user, () -> { + if (eligibilityOptions != null && !bypassesPlayerEligibility(eligibilityOptions) + && !checkPlayerEligibility(user, eligibilityOptions)) { + return CompletableFuture.failedFuture(new PlayerEligibilityDeferredException()); + } + return requestInjectionAsync(inject, user, placeholders, replayState, injectionKey, occurrenceId, + eligibilityOptions); + }); CompletionStage result = inject.isSynchronize() && inject.supportsAsyncSynchronization() ? inject.runSynchronizedAsync(request) : request.get(); @@ -1295,7 +1336,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) { ReplayState previous = ACTIVE_REPLAY_STATE.get(); String previousKey = ACTIVE_REPLAY_KEY.get(); String previousOccurrenceId = ACTIVE_REPLAY_OCCURRENCE_ID.get(); @@ -1303,8 +1345,14 @@ 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(getConfig().getConfigData())) { + playerEligibility = () -> checkPlayerEligibility(user, eligibilityOptions); + } + AdvancedCoreUser.AsyncActionCollection actionCollection = playerEligibility == null + ? user.beginAsyncActionCollection(replayState, placeholders, injectionKey) + : user.beginAsyncActionCollection(replayState, placeholders, injectionKey, playerEligibility); CompletionStage result = CompletableFuture.failedFuture( new IllegalStateException("Reward injection did not produce a result")); try { @@ -1381,6 +1429,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(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; @@ -1584,6 +1642,52 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R .thenCompose(decision -> finishRewardAsync(user, stableOptions, decision)); } + /** Deterministic player-context check, invoked only on the owning scheduler. */ + public boolean checkPlayerEligibility(AdvancedCoreUser user, RewardOptions options) { + if (bypassesPlayerEligibility(options) || !RewardEligibility.isConfigured(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(); + } + + private 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 hasApprovedPartialReplayChance(RewardOptions options) { + if (!RewardEligibility.isConfigured(getConfig().getConfigData()) + || (!"1".equals(options.getPlaceholders().get(chanceReplayKey(options))) + && (options.getAsyncReplayState() == null + || !"1".equals(options.getAsyncReplayState().replayMetadata(chanceReplayKey(options)))))) return false; + return options.getCompletedAsyncInjections() > 0 + || options.getAsyncReplayProgress().values().stream().anyMatch(count -> count != null && count > 0) + || (options.getAsyncReplayState() != null && options.getAsyncReplayState().highestCompletedCount() > 0); + } + private CompletionStage evaluateLiveRewardDecision(AdvancedCoreUser user, RewardOptions rewardOptions) { boolean liveOnline = rewardOptions.isLivePlayerStateSet() ? rewardOptions.isOnline() : user.isOnline(); @@ -1594,11 +1698,13 @@ private CompletionStage evaluateLiveRewardDecision(AdvancedC if (handle.isPreProcess()) rewardOptions.addPlaceholder(handle.getKey(), handle.getValue(this, user)); } - boolean allowOffline = false; - boolean canGive = true; + boolean canGive = checkPlayerEligibility(user, rewardOptions); + boolean allowOffline = !canGive; + boolean chanceApproved = false; if (!rewardOptions.isIgnoreRequirements()) { for (RequirementInject inject : plugin.getRewardHandler().getInjectedRequirements()) { try { + if ("Chance".equals(inject.getPath()) && hasApprovedPartialReplayChance(rewardOptions)) continue; if (!inject.onRequirementRequest(this, user, getConfig().getConfigData(), rewardOptions)) { canGive = false; if (!inject.isAllowReattempt()) { @@ -1606,6 +1712,8 @@ private CompletionStage evaluateLiveRewardDecision(AdvancedC new LiveRewardDecision(false, false, vanished, false, true)); } allowOffline = true; + } else if ("Chance".equals(inject.getPath())) { + chanceApproved = true; } } catch (Exception e) { plugin.debug("Failed to check requirement " + inject.getPath()); @@ -1618,6 +1726,12 @@ private CompletionStage evaluateLiveRewardDecision(AdvancedC } } } + if (canGive && chanceApproved && RewardEligibility.isConfigured(getConfig().getConfigData())) { + String key = chanceReplayKey(rewardOptions); + rewardOptions.addPlaceholder(key, "1"); + if (rewardOptions.getAsyncReplayState() != null) + rewardOptions.getAsyncReplayState().recordReplayMetadata(key, "1"); + } boolean verifyOnline = !rewardOptions.isOnline() || rewardOptions.getServer() != null; boolean liveOffline = verifyOnline && !liveOnline; return CompletableFuture.completedFuture( @@ -1699,6 +1813,9 @@ private CompletionStage deferRewardAsync(AdvancedCoreUser user, RewardOpti return CompletableFuture.failedFuture(new OfflineReplayDeferredException()); } user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); + if (!isDurableReplay(rewardOptions) && RewardEligibility.isConfigured(getConfig().getConfigData())) { + plugin.getRewardHandler().getRewardEligibility().queued(user); + } return rewardOptions.getAsyncReplayCheckpointConsumer() == null ? CompletableFuture.completedFuture(null) : CompletableFuture.failedFuture(new OfflineReplayDeferredException()); @@ -1745,7 +1862,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 +1887,26 @@ 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); + } + return deferRewardWithoutBlockingOwner(user, deliveryOptions); + }); } private CompletionStage prepareAndGiveRewardUserAsync(AdvancedCoreUser user, HashMap phs, @@ -1791,7 +1927,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 +1938,8 @@ private static boolean hasPersistedReplayCheckpoint(RewardOptions options) { } private boolean requiresAwaitedDelivery(RewardOptions options) { - return options.getAsyncReplayCheckpointConsumer() != null || hasAsyncRewardInjection() + return RewardEligibility.isConfigured(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..1c75f2d70e --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java @@ -0,0 +1,285 @@ +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 org.bukkit.GameMode; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +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 final AdvancedCorePlugin plugin; + private final RewardHandler handler; + private final LinkedHashMap pending = new LinkedHashMap<>(); + private ScheduledFuture fallback; + private boolean closed; + + RewardEligibility(AdvancedCorePlugin plugin, RewardHandler handler) { + this.plugin = plugin; + this.handler = handler; + } + + /** + * 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); + } + + /** Fast deterministic predicate; callers must already hold the player owner. */ + public boolean isEligible(ConfigurationSection data, AdvancedCoreUser user) { + if (!isConfigured(data)) return true; + synchronized (this) { if (closed) return false; } + if (!validModes(data, "GameModes") || !validModes(data, "BlackListedGameModes")) return false; + List allowed = data.getStringList("GameModes"); + List denied = data.getStringList("BlackListedGameModes"); + if (allowed.isEmpty() && denied.isEmpty()) return true; + Player player = user.getPlayer(); + if (player == null || !player.isOnline()) 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; + if (!data.isList(path)) return false; + List values = data.getList(path); + if (values == null || values.size() > GameMode.values().length) return false; + for (Object value : values) { + if (!(value 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 (String candidate : modes) if (mode.name().equalsIgnoreCase(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(); + if (player == null || !player.isOnline()) return; + synchronized (this) { + if (closed) return; + Pending current = pending.get(user.getUUID()); + if (current == null) { + if (pending.size() >= MAX_PENDING_PLAYERS) return; + current = new Pending(user, player); + pending.put(user.getUUID(), current); + } + current.user = user; + current.player = player; + current.revision++; + startFallback(); + } + } + + /** Publication can race a signal that observed an empty queue; renew the wake-up afterwards. */ + synchronized void queued(AdvancedCoreUser user) { + if (closed) return; + Pending current = pending.get(user.getUUID()); + if (current == null) { + if (pending.size() >= MAX_PENDING_PLAYERS) return; + current = new Pending(user, null); + pending.put(user.getUUID(), current); + } + current.revision++; + startFallback(); + } + + /** + * 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) { + 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 = pending.get(user.getUUID()); + 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); + pending.put(user.getUUID(), current); + } + 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) { + synchronized (this) { + if (closed || pending.get(user.getUUID()) != captured || captured.running != result) return; + } + final boolean claimedBeforeCheck; + final CompletionStage check; + try { + claimedBeforeCheck = user.hasPendingRewardReplay(); + 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); + } + }); + } + + private void finishSignal(AdvancedCoreUser user, Pending captured, long revision, + CompletableFuture result, boolean canRetireHint, Throwable failure) { + synchronized (this) { + if (pending.get(user.getUUID()) == captured && captured.running == result) { + captured.running = null; + 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); + } + + public synchronized void disconnected(Player player) { + pending.entrySet().removeIf(entry -> entry.getValue().player == player && entry.getValue().running == null); + stopIdleFallback(); + } + + 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); + } + for (Pending entry : batch) signal(entry.user); + } + + private void stopIdleFallback() { + if (pending.isEmpty() && 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 : pending.values()) if (entry.running != null) running.add(entry.running); + pending.clear(); + } + 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 CompletableFuture running; + private Pending(AdvancedCoreUser user, Player player) { this.user = user; this.player = player; } + } +} 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..29c611d97d --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementGameMode.java @@ -0,0 +1,41 @@ +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 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/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 40a8f6a43c..9a33160c29 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,12 @@ public static final class AsyncActionCollection { private final String checkpointKey; private final String snapshotKey; private final AdvancedCorePlugin plugin; + private final Supplier playerEligibility; 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) { this.previous = previous; this.owner = owner; this.replayState = replayState; @@ -104,6 +108,11 @@ private AsyncActionCollection(AsyncActionCollection previous, AdvancedCoreUser o this.checkpointKey = Reward.legacyActionReplayKey(injectionKey); this.snapshotKey = checkpointKey + ACTION_SNAPSHOT_SUFFIX; this.plugin = plugin; + this.playerEligibility = playerEligibility; + } + + private boolean isPlayerEligible() { + return playerEligibility == null || Boolean.TRUE.equals(playerEligibility.get()); } private synchronized boolean add(Supplier> action, String descriptor) { @@ -374,8 +383,14 @@ 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); ASYNC_ACTION_COLLECTION.set(collection); return collection; } @@ -390,6 +405,79 @@ 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.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; @@ -652,6 +740,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(); @@ -1102,6 +1192,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 +1243,52 @@ 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()); + } + } + } + private void checkOfflineRewards(ReplayPlayerState capturedState) { if (!plugin.getOptions().isProcessRewards()) { plugin.debug("Processing rewards is disabled"); @@ -1189,6 +1327,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(); @@ -1964,9 +2104,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 +2224,15 @@ 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; + 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 +2240,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 +2258,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 +2325,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 +2747,36 @@ 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(), PlaceholderUtils.replacePlaceHolder(templates, placeholders)); - return Reward.replayCommandSequence(plugin, placeholders, "player", templates, cmds, (command, ignoredIndex) -> { + return Reward.replayCommandSequence(plugin, placeholders, "player", templates, cmds, replayState, replayKey, + (command, ignoredIndex) -> { final Player player = getPlayer(); 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); @@ -2573,13 +2791,18 @@ public CompletionStage validatePlayerCommandAvailabilityAsync() { : 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 +2810,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..f06cbce7ca --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java @@ -0,0 +1,38 @@ +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.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 onQuit(PlayerQuitEvent event) { + plugin.getRewardHandler().getRewardEligibility().disconnected(event.getPlayer()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RetiringOwnerScheduler.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RetiringOwnerScheduler.java new file mode 100644 index 0000000000..fc4d8c021a --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RetiringOwnerScheduler.java @@ -0,0 +1,100 @@ +package 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/api/rewards/RewardEligibilityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityTest.java new file mode 100644 index 0000000000..9050e9c072 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityTest.java @@ -0,0 +1,119 @@ +package 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 = new RewardEligibility(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 = new RewardEligibility(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"); + when(user.hasPendingRewardReplay()).thenAnswer(call -> { + synchronized (plugin) { return false; } + }); + when(user.checkOfflineRewardsAsync()).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + RewardEligibility eligibility = new RewardEligibility(plugin, handler); + 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 = new RewardEligibility(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 = new RewardEligibility(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(); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java new file mode 100644 index 0000000000..ba45791d2c --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java @@ -0,0 +1,272 @@ +package 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.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); + 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. + reward.plugin = plugin; + reward.getConfig().plugin = 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 = 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/api/rewards/RewardGamemodeEconomyTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java new file mode 100644 index 0000000000..534203da1e --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java @@ -0,0 +1,315 @@ +package 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"); + } + + 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 -> { 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); + AtomicReference currentPlayer = new AtomicReference<>(forcedOffline ? null : player); + when(player.isOnline()).thenAnswer(call -> currentPlayer.get() == player); + 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(); + 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. + reward.plugin = plugin; + reward.getConfig().plugin = 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) { + target.giveMoney(2.0); + target.giveMoney(amount.get()); + 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); + var replay = handler.getRewardEligibility().signal(user).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/api/rewards/RewardGamemodeMutationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java new file mode 100644 index 0000000000..946d24c985 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java @@ -0,0 +1,240 @@ +package 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.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); + 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. + reward.plugin = plugin; + reward.getConfig().plugin = 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 = 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/api/rewards/RewardGamemodeReplayTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeReplayTest.java new file mode 100644 index 0000000000..c9a04ceb39 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeReplayTest.java @@ -0,0 +1,407 @@ +package 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.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); } + + 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); + 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. + reward.plugin = plugin; + reward.getConfig().plugin = 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)); + 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 Player player = mock(Player.class); + final UUID id = UUID.randomUUID(); + final AtomicReference mode = new AtomicReference<>(GameMode.SURVIVAL); + 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); + 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()); + 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(); + } + + 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); + reward.plugin = plugin; + reward.getConfig().plugin = 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/listeners/RewardGamemodeListenerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/RewardGamemodeListenerTest.java new file mode 100644 index 0000000000..0a5d3dc2de --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/RewardGamemodeListenerTest.java @@ -0,0 +1,54 @@ +package 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); + } +} 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/rewards/BuiltinRequirementRegistrationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRequirementRegistrationTest.java index 620246e9a1..294e25926e 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,7 @@ 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")); } private void assertRequirementPaths(BiConsumer registrar, diff --git a/docs/reward-gamemode-eligibility.md b/docs/reward-gamemode-eligibility.md new file mode 100644 index 0000000000..c5ade694ef --- /dev/null +++ b/docs/reward-gamemode-eligibility.md @@ -0,0 +1,45 @@ +# 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. Invalid types or names fail closed. + +```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. + +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. From ea5345aacd0890b1461df16da3fcbb39b697e624 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:31:18 -0600 Subject: [PATCH 2/9] Preserve deferred reward wakeups across player session changes --- .../advancedcore/api/rewards/Reward.java | 46 +- .../api/rewards/RewardEligibility.java | 180 ++++- .../api/user/AdvancedCoreUser.java | 20 +- .../listeners/RewardEligibilityListener.java | 7 + .../api/rewards/RewardEligibilityTest.java | 391 ++++++++++ .../rewards/RewardGamemodeCommandTest.java | 13 +- .../rewards/RewardGamemodeEconomyTest.java | 45 +- .../rewards/RewardGamemodeMutationTest.java | 13 +- .../api/rewards/RewardGamemodeReplayTest.java | 19 + .../api/rewards/RewardGamemodeTimedTest.java | 681 ++++++++++++++++++ .../listeners/RewardGamemodeListenerTest.java | 11 + docs/reward-gamemode-eligibility.md | 30 + 12 files changed, 1425 insertions(+), 31 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java 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 0fab6f25b8..56b1025d24 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 @@ -1275,10 +1275,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"))); @@ -1289,7 +1307,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")); @@ -1310,9 +1328,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); } @@ -1665,7 +1684,7 @@ public static RuntimeException playerEligibilityDeferred() { return new PlayerEligibilityDeferredException(); } - private static boolean isPlayerEligibilityDeferred(Throwable failure) { + public static boolean isPlayerEligibilityDeferred(Throwable failure) { for (Throwable current = failure; current != null; current = current.getCause()) { if (current instanceof PlayerEligibilityDeferredException) return true; } @@ -1699,6 +1718,7 @@ private CompletionStage evaluateLiveRewardDecision(AdvancedC } boolean canGive = checkPlayerEligibility(user, rewardOptions); + boolean playerEligibilityDenied = !canGive; boolean allowOffline = !canGive; boolean chanceApproved = false; if (!rewardOptions.isIgnoreRequirements()) { @@ -1709,7 +1729,7 @@ private CompletionStage evaluateLiveRewardDecision(AdvancedC canGive = false; if (!inject.isAllowReattempt()) { return CompletableFuture.completedFuture( - new LiveRewardDecision(false, false, vanished, false, true)); + new LiveRewardDecision(false, false, vanished, false, true, playerEligibilityDenied)); } allowOffline = true; } else if ("Chance".equals(inject.getPath())) { @@ -1735,12 +1755,16 @@ private CompletionStage evaluateLiveRewardDecision(AdvancedC boolean verifyOnline = !rewardOptions.isOnline() || rewardOptions.getServer() != null; boolean liveOffline = verifyOnline && !liveOnline; return CompletableFuture.completedFuture( - new LiveRewardDecision(canGive, allowOffline, vanished, liveOffline, false)); + new LiveRewardDecision(canGive, allowOffline, vanished, liveOffline, false, playerEligibilityDenied)); } 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); @@ -1774,7 +1798,7 @@ private CompletionStage deferRewardWithoutBlockingOwner(AdvancedCoreUser u } private record LiveRewardDecision(boolean canGive, boolean allowOffline, boolean vanished, boolean liveOffline, - boolean terminalDenied) { } + boolean terminalDenied, boolean playerEligibilityDenied) { } private CompletionStage continueOffServerThread(Supplier> request) { CompletableFuture> handoff = new CompletableFuture<>(); @@ -1905,6 +1929,10 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< 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); }); } 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 index 1c75f2d70e..880264777f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java @@ -10,10 +10,12 @@ 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; @@ -23,6 +25,10 @@ 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<>(); @@ -30,8 +36,13 @@ public final class RewardEligibility { private boolean closed; 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"); } /** @@ -73,8 +84,19 @@ public static boolean isConfigured(ConfigurationSection data) { /** Fast deterministic predicate; callers must already hold the player owner. */ public boolean isEligible(ConfigurationSection data, AdvancedCoreUser user) { if (!isConfigured(data)) return true; - synchronized (this) { if (closed) return false; } if (!validModes(data, "GameModes") || !validModes(data, "BlackListedGameModes")) return false; + Pending observed; + synchronized (this) { + observed = pending.get(user.getUUID()); + if (closed || observed != null && observed.awaitingLogin) return false; + } + if (observed != null) { + Player current = user.getPlayer(); + synchronized (this) { + if (closed || pending.get(user.getUUID()) != observed || observed.awaitingLogin + || observed.player == null || observed.player != current) return false; + } + } List allowed = data.getStringList("GameModes"); List denied = data.getStringList("BlackListedGameModes"); if (allowed.isEmpty() && denied.isEmpty()) return true; @@ -106,13 +128,19 @@ private static boolean containsMode(List modes, GameMode mode) { /** 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 = pending.get(user.getUUID()); + if (closed || current != null && (current.awaitingLogin || current.player != player)) return; + } if (player == null || !player.isOnline()) return; synchronized (this) { if (closed) return; Pending current = pending.get(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) return; - current = new Pending(user, player); + current = new Pending(user, player, nanoClock.getAsLong()); pending.put(user.getUUID(), current); } current.user = user; @@ -123,16 +151,33 @@ void deferred(AdvancedCoreUser user) { } /** Publication can race a signal that observed an empty queue; renew the wake-up afterwards. */ - synchronized void queued(AdvancedCoreUser user) { - if (closed) return; + 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 = pending.get(user.getUUID()); if (current == null) { - if (pending.size() >= MAX_PENDING_PLAYERS) return; - current = new Pending(user, null); + if (pending.size() >= MAX_PENDING_PLAYERS) return false; + current = new Pending(user, null, nanoClock.getAsLong()); pending.put(user.getUUID(), current); } current.revision++; startFallback(); + return true; } /** @@ -141,6 +186,10 @@ synchronized void queued(AdvancedCoreUser user) { * This method accepts the consumer's user identity, including offline-mode identities. */ public CompletionStage signal(AdvancedCoreUser user) { + return signal(user, null); + } + + 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")); @@ -150,12 +199,22 @@ public CompletionStage signal(AdvancedCoreUser user) { if (closed || !plugin.isEnabled()) return CompletableFuture.failedFuture( new IllegalStateException("Reward eligibility is closed")); current = pending.get(user.getUUID()); + if (current != null && current.awaitingLogin) 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); + 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; @@ -175,13 +234,26 @@ public CompletionStage signal(AdvancedCoreUser user) { private void runSignalCheck(AdvancedCoreUser user, Pending captured, long revision, CompletableFuture result) { + boolean awaitingLogin; + boolean needsBinding; synchronized (this) { if (closed || pending.get(user.getUUID()) != captured || captured.running != result) return; + awaitingLogin = captured.awaitingLogin; + needsBinding = captured.player == null; + } + if (awaitingLogin) { + finishSignal(user, captured, revision, result, false, null); + return; + } + if (needsBinding) { + resolveSignalPlayer(user, captured, revision, result); + return; } final boolean claimedBeforeCheck; final CompletionStage check; try { claimedBeforeCheck = user.hasPendingRewardReplay(); + user.checkDelayedTimedRewards(); check = user.checkOfflineRewardsAsync(); if (check == null) throw new IllegalStateException("Offline replay returned no completion stage"); } catch (Throwable failure) { @@ -206,6 +278,40 @@ private void runSignalCheck(AdvancedCoreUser user, Pending captured, long revisi }); } + /** 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 || pending.get(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 || pending.get(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) { @@ -229,9 +335,52 @@ public void signal(Player player) { for (AdvancedCoreUser user : users) signal(user); } - public synchronized void disconnected(Player player) { - pending.entrySet().removeIf(entry -> entry.getValue().player == player && entry.getValue().running == null); - stopIdleFallback(); + /** + * 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 : pending.values()) { + 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 = pending.get(user.getUUID()); + if (current == null) return CompletableFuture.completedFuture(null); + current.user = user; + current.player = player; + current.awaitingLogin = false; + current.revision++; + } + return signal(user); + }); } private void sweep() { @@ -246,7 +395,7 @@ private void sweep() { // Rotate bounded hints to give every pending player a turn. for (Pending entry : batch) pending.put(entry.user.getUUID(), entry); } - for (Pending entry : batch) signal(entry.user); + for (Pending entry : batch) signal(entry.user, entry); } private void stopIdleFallback() { @@ -279,7 +428,14 @@ private static final class Pending { private AdvancedCoreUser user; private Player player; private long revision; + private boolean awaitingLogin; private CompletableFuture running; - private Pending(AdvancedCoreUser user, Player player) { this.user = user; this.player = player; } + 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/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 9a33160c29..a89828cf29 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 @@ -1535,6 +1535,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(); @@ -1559,15 +1562,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() { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java index f06cbce7ca..23b0e30f36 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java @@ -31,6 +31,13 @@ public void onWorldChange(PlayerChangedWorldEvent event) { } } + @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/api/rewards/RewardEligibilityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityTest.java index 9050e9c072..05e171aac5 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityTest.java @@ -54,11 +54,15 @@ void ownerSignalDoesNotWaitForStorageOwnershipMonitor() throws Exception { 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)); RewardEligibility eligibility = new RewardEligibility(plugin, handler); + eligibility.deferred(user); try { java.util.concurrent.CompletionStage completion; synchronized (plugin) { @@ -116,4 +120,391 @@ void closedSignalCannotResumeItsDiscardedWorkerCallback() { verify(user, never()).hasPendingRewardReplay(); verify(user, never()).checkOfflineRewardsAsync(); } + @Test + void fallbackDoesNotReplayBeforeItsFirstDeadline() { + try (SignalFixture fixture = new SignalFixture()) { + fixture.eligibility.queued(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()) { + fixture.eligibility.queued(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)); + fixture.eligibility.queued(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 -> { + fixture.eligibility.queued(user); + return java.util.concurrent.CompletableFuture.completedFuture(null); + }); + users.add(user); + fixture.eligibility.queued(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(); + fixture.eligibility.queued(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(); + fixture.eligibility.queued(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); + fixture.eligibility.deferred(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 -> { + fixture.eligibility.queued(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)); + fixture.eligibility.queued(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()) { + fixture.eligibility.queued(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 allowedModeCannotBypassTheQuitGateBeforeAcceptedLogin() { + try (SignalFixture fixture = new SignalFixture()) { + Player previous = mock(Player.class); + when(previous.isOnline()).thenReturn(true); + when(fixture.user.getPlayer()).thenReturn(previous); + fixture.eligibility.deferred(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)); + fixture.eligibility.deferred(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); + fixture.eligibility.queued(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); + fixture.eligibility.deferred(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() { + 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 = new RewardEligibility(plugin, handler, clock::get); + when(user.checkOfflineRewardsAsync()).thenAnswer(call -> { + eligibility.queued(user); + return java.util.concurrent.CompletableFuture.completedFuture(null); + }); + eligibility.signalLogin(user, player).toCompletableFuture().join(); + } + + @Override public void close() { eligibility.close(); } + } + } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java index ba45791d2c..40e37810f2 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java @@ -25,6 +25,7 @@ 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; @@ -148,6 +149,14 @@ private void commandScenario(String kind, boolean policy, boolean disconnect, in 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 -> { assertTrue(playerOwner.get(), "gamemode must only be read on the player owner scheduler"); @@ -226,7 +235,9 @@ else if (kind.equals("mixed-player")) { assertEquals(1, persisted.get().size()); mode.set(GameMode.SURVIVAL); when(player.isOnline()).thenReturn(true); - var replay = handler.getRewardEligibility().signal(user).toCompletableFuture(); + 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") diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java index 534203da1e..25fa9e3595 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java @@ -103,6 +103,13 @@ class RewardGamemodeEconomyTest { 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"); } @@ -160,7 +167,14 @@ private void economyScenario(boolean withdrawal, String policy, String transitio : null; ScheduledExecutorService timer = mock(ScheduledExecutorService.class); when(plugin.getTimer()).thenReturn(timer); - doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }).when(timer).execute(any()); + 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); @@ -203,6 +217,8 @@ private void economyScenario(boolean withdrawal, String policy, String transitio 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()); @@ -225,8 +241,12 @@ private void economyScenario(boolean withdrawal, String policy, String transitio 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; } }); @@ -265,7 +285,28 @@ private void economyScenario(boolean withdrawal, String policy, String transitio amount.set(withdrawal ? -7.0 : 7.0); mode.set(GameMode.SURVIVAL); if (!transition.equals("mode")) currentPlayer.set(replacement); - var replay = handler.getRewardEligibility().signal(user).toCompletableFuture(); + 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:"; diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java index 946d24c985..e9d0447a37 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java @@ -25,6 +25,7 @@ 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; @@ -137,6 +138,14 @@ private void mutationScenario(boolean itemAction, String rule, boolean changeMod 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()); if (retiringScheduler != null) doAnswer(call -> { retiringScheduler.retireNext(retirement == 1); return null; @@ -209,7 +218,9 @@ private void mutationScenario(boolean itemAction, String rule, boolean changeMod if (inventoryHandler != null) assertTrue(inventoryHandler.getItems().isEmpty()); mode.set(GameMode.SURVIVAL); when(player.isOnline()).thenReturn(true); - var replay = handler.getRewardEligibility().signal(user).toCompletableFuture(); + var replay = (disconnect || retirement != 0 + ? handler.getRewardEligibility().signalLogin(user, player) + : handler.getRewardEligibility().signal(user)).toCompletableFuture(); runNext(callbacks); replay.get(2, TimeUnit.SECONDS); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeReplayTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeReplayTest.java index c9a04ceb39..7212536c0c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeReplayTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeReplayTest.java @@ -22,6 +22,7 @@ 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; @@ -83,6 +84,14 @@ private void replayScenario(boolean yield, boolean restart) { 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")); @@ -109,6 +118,7 @@ private void replayScenario(boolean yield, boolean restart) { 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"); @@ -302,6 +312,7 @@ private final class NestedFixture implements AutoCloseable { 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; @@ -341,11 +352,19 @@ private final class NestedFixture implements AutoCloseable { 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<>()); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java new file mode 100644 index 0000000000..c173d65eaa --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java @@ -0,0 +1,681 @@ +package 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.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.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.listeners.AdvancedCoreLoginEvent; +import com.bencodez.advancedcore.listeners.RewardEligibilityListener; + +class RewardGamemodeTimedTest { + @TempDir File directory; + + @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 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 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 AtomicReference fallbackSweep = new AtomicReference<>(); + final ScheduledExecutorService delayed = mock(ScheduledExecutorService.class); + final org.mockito.MockedStatic bukkit = mockStatic(Bukkit.class); + final RewardHandler handler; + final AdvancedCoreUser user; + RewardEligibility customEligibility; + + Fixture() { this(null); } + + Fixture(java.util.function.LongSupplier nanoClock) { + when(plugin.isEnabled()).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.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); + handler = new RewardHandler(plugin) { + @Override public ScheduledExecutorService getDelayedTimer() { return delayed; } + @Override public RewardEligibility getRewardEligibility() { + return customEligibility == null ? super.getRewardEligibility() : customEligibility; + } + }; + if (nanoClock != null) { + handler.getRewardEligibility().close(); + customEligibility = new RewardEligibility(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.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); + reward.plugin = plugin; + reward.getConfig().plugin = plugin; + handler.getRewards().add(reward); + 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() { + handler.getRewardEligibility().close(); + delayed.shutdownNow(); + AdvancedCorePlugin.setInstance(null); + bukkit.close(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/RewardGamemodeListenerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/RewardGamemodeListenerTest.java index 0a5d3dc2de..f4c028e796 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/RewardGamemodeListenerTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/RewardGamemodeListenerTest.java @@ -51,4 +51,15 @@ void transitionWakeupRunsAfterCommitOnPlayerScheduler() { 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/docs/reward-gamemode-eligibility.md b/docs/reward-gamemode-eligibility.md index c5ade694ef..333b7487e2 100644 --- a/docs/reward-gamemode-eligibility.md +++ b/docs/reward-gamemode-eligibility.md @@ -26,6 +26,36 @@ 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. +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. + 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. From 098e0c27204dd21695fb6c128f570afd3361b9c9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:44:47 -0600 Subject: [PATCH 3/9] Fix reward eligibility recovery for existing and saturated sessions --- .../advancedcore/AdvancedCorePlugin.java | 1 + .../api/rewards/RewardEligibility.java | 130 ++++++-- .../rewards/RewardEligibilityOverflow.java | 148 +++++++++ .../api/rewards/RewardEligibilityStartup.java | 128 ++++++++ .../listeners/RewardEligibilityListener.java | 7 + .../RewardEligibilityOverflowTest.java | 247 +++++++++++++++ .../rewards/RewardEligibilityStartupTest.java | 288 ++++++++++++++++++ .../api/rewards/RewardGamemodeTimedTest.java | 48 +++ docs/reward-gamemode-eligibility.md | 31 ++ 9 files changed, 1002 insertions(+), 26 deletions(-) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityOverflow.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartup.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityOverflowTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartupTest.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index ce42b0bcda..880bbed9a8 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -1380,6 +1380,7 @@ public void onEnable() { } getRewardHandler().checkSubRewards(); getRewardHandler().checkDirectlyDefinedRewardFiles(); + getRewardHandler().getRewardEligibility().bootstrapOnlinePlayers(); } /** 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 index 880264777f..d820395c26 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java @@ -32,8 +32,11 @@ public final class RewardEligibility { 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); @@ -43,6 +46,24 @@ public final class RewardEligibility { 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)); } /** @@ -87,21 +108,22 @@ public boolean isEligible(ConfigurationSection data, AdvancedCoreUser user) { if (!validModes(data, "GameModes") || !validModes(data, "BlackListedGameModes")) return false; Pending observed; synchronized (this) { - observed = pending.get(user.getUUID()); + observed = findPending(user.getUUID()); if (closed || observed != null && observed.awaitingLogin) return false; } if (observed != null) { Player current = user.getPlayer(); synchronized (this) { - if (closed || pending.get(user.getUUID()) != observed || observed.awaitingLogin + if (closed || findPending(user.getUUID()) != observed || observed.awaitingLogin || observed.player == null || observed.player != current) return false; } } List allowed = data.getStringList("GameModes"); List denied = data.getStringList("BlackListedGameModes"); - if (allowed.isEmpty() && denied.isEmpty()) return true; + if (allowed.isEmpty() && denied.isEmpty() && !overflow.enabled()) return true; Player player = user.getPlayer(); if (player == null || !player.isOnline()) 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; @@ -129,17 +151,17 @@ private static boolean containsMode(List modes, GameMode mode) { void deferred(AdvancedCoreUser user) { Player player = user.getPlayer(); synchronized (this) { - Pending current = pending.get(user.getUUID()); + Pending current = findPending(user.getUUID()); if (closed || current != null && (current.awaitingLogin || current.player != player)) return; } if (player == null || !player.isOnline()) return; synchronized (this) { if (closed) return; - Pending current = pending.get(user.getUUID()); + 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) return; + if (pending.size() >= MAX_PENDING_PLAYERS) { overflow.enable(); startFallback(); return; } current = new Pending(user, player, nanoClock.getAsLong()); pending.put(user.getUUID(), current); } @@ -169,9 +191,9 @@ public synchronized boolean retainTimedRetry(AdvancedCoreUser user) { private synchronized boolean retainHint(AdvancedCoreUser user) { if (closed) return false; - Pending current = pending.get(user.getUUID()); + Pending current = findPending(user.getUUID()); if (current == null) { - if (pending.size() >= MAX_PENDING_PLAYERS) return false; + if (pending.size() >= MAX_PENDING_PLAYERS) { overflow.enable(); startFallback(); return false; } current = new Pending(user, null, nanoClock.getAsLong()); pending.put(user.getUUID(), current); } @@ -189,6 +211,37 @@ 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( @@ -198,8 +251,9 @@ private CompletionStage signal(AdvancedCoreUser user, Pending fallbackEntr synchronized (this) { if (closed || !plugin.isEnabled()) return CompletableFuture.failedFuture( new IllegalStateException("Reward eligibility is closed")); - current = pending.get(user.getUUID()); - if (current != null && current.awaitingLogin) return CompletableFuture.completedFuture(null); + 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); @@ -237,12 +291,19 @@ private void runSignalCheck(AdvancedCoreUser user, Pending captured, long revisi boolean awaitingLogin; boolean needsBinding; synchronized (this) { - if (closed || pending.get(user.getUUID()) != captured || captured.running != result) return; + if (closed || findPending(user.getUUID()) != captured || captured.running != result) return; awaitingLogin = captured.awaitingLogin; needsBinding = captured.player == null; } if (awaitingLogin) { - finishSignal(user, captured, revision, result, false, null); + // 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) { @@ -283,7 +344,7 @@ private void resolveSignalPlayer(AdvancedCoreUser user, Pending captured, long r CompletableFuture result) { Reward.continueOnResolvedPlayerThread(plugin, user, player -> { synchronized (this) { - if (closed || pending.get(user.getUUID()) != captured || captured.running != result + if (closed || findPending(user.getUUID()) != captured || captured.running != result || captured.awaitingLogin) return CompletableFuture.completedFuture(false); if (captured.player != null) return CompletableFuture.completedFuture(true); } @@ -291,7 +352,7 @@ private void resolveSignalPlayer(AdvancedCoreUser user, Pending captured, long r && player.getMetadata(LOGIN_RECEIPT_KEY).stream().anyMatch(value -> value.getOwningPlugin() == plugin && Boolean.TRUE.equals(value.value())); synchronized (this) { - if (closed || pending.get(user.getUUID()) != captured || captured.running != result + 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); @@ -315,9 +376,11 @@ private void resolveSignalPlayer(AdvancedCoreUser user, Pending captured, long r private void finishSignal(AdvancedCoreUser user, Pending captured, long revision, CompletableFuture result, boolean canRetireHint, Throwable failure) { synchronized (this) { - if (pending.get(user.getUUID()) == captured && captured.running == result) { + if (findPending(user.getUUID()) == captured && captured.running == result) { captured.running = null; - if (failure == null && canRetireHint && captured.revision == revision) + 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(); @@ -333,6 +396,7 @@ public void signal(Player player) { 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); } /** @@ -342,7 +406,7 @@ public void signal(Player player) { public void disconnected(Player player) { player.removeMetadata(LOGIN_RECEIPT_KEY, plugin); synchronized (this) { - for (Pending entry : pending.values()) { + for (Pending entry : allPending()) { if (entry.player == player) { entry.player = null; entry.awaitingLogin = true; @@ -372,14 +436,16 @@ public CompletionStage signalLogin(AdvancedCoreUser user, Player player) { player.setMetadata(LOGIN_RECEIPT_KEY, new FixedMetadataValue(plugin, Boolean.TRUE)); synchronized (this) { if (closed) return CompletableFuture.completedFuture(null); - Pending current = pending.get(user.getUUID()); - if (current == null) return CompletableFuture.completedFuture(null); - current.user = user; - current.player = player; - current.awaitingLogin = false; - current.revision++; + 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.revision++; + } } - return signal(user); + return overflow.enabled() ? signalOverflow(user, player) : signal(user); }); } @@ -395,11 +461,21 @@ private void sweep() { // 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() && fallback != null) { fallback.cancel(false); fallback = null; } + if (pending.isEmpty() && overflowRunning.isEmpty() && !overflow.enabled() && fallback != null) { fallback.cancel(false); fallback = null; } } private void startFallback() { @@ -417,9 +493,11 @@ public void close() { closed = true; if (fallback != null) fallback.cancel(false); fallback = null; - for (Pending entry : pending.values()) if (entry.running != null) running.add(entry.running); + 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")); } 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..b4a604b5f7 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartup.java @@ -0,0 +1,128 @@ +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() || 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/listeners/RewardEligibilityListener.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java index 23b0e30f36..07fcf46418 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java @@ -14,6 +14,13 @@ 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) + handler.getRewardEligibility().loginPending(event.getPlayer()); + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onGameModeChange(PlayerGameModeChangeEvent event) { if (!plugin.isEnabled() || event.isCancelled()) return; diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityOverflowTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityOverflowTest.java new file mode 100644 index 0000000000..5d092faa43 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityOverflowTest.java @@ -0,0 +1,247 @@ +package 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 <= RewardEligibilityOverflow.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 < RewardEligibilityOverflow.BATCH; i++) + verify(nodes.get(i).user).checkOfflineRewardsAsync(); + verify(nodes.get(RewardEligibilityOverflow.BATCH).user, never()).checkOfflineRewardsAsync(); + for (int i = 0; i < RewardEligibilityOverflow.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(RewardEligibilityOverflow.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 < RewardEligibilityOverflow.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 RewardEligibilityOverflow 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 = new RewardEligibility(plugin, handler, clock::get); + when(handler.getRewardEligibility()).thenReturn(eligibility); + var field = RewardEligibility.class.getDeclaredField("overflow"); + field.setAccessible(true); + overflow = (RewardEligibilityOverflow) 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/api/rewards/RewardEligibilityStartupTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartupTest.java new file mode 100644 index 0000000000..560a459ee4 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartupTest.java @@ -0,0 +1,288 @@ +package 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.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; + +class RewardEligibilityStartupTest { + @Test void existingOnlineSessionWithoutAnAuthenticationPluginGetsAReceipt() { + try (Fixture fixture = new Fixture()) { + RewardEligibilityStartup.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 startupRebindsAnAlreadyAwaitingUnknownHintAndWakesItsRealQueue() { + try (Fixture fixture = new Fixture()) { + when(fixture.user.checkOfflineRewardsAsync()).thenReturn(CompletableFuture.completedFuture(null)); + fixture.eligibility.queued(fixture.user); + fixture.eligibility.signal(fixture.user).toCompletableFuture().join(); + verify(fixture.user, never()).checkOfflineRewardsAsync(); + RewardEligibilityStartup.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); + RewardEligibilityStartup.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; + }); + RewardEligibilityStartup.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); + RewardEligibilityStartup.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; }); + RewardEligibilityStartup.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(RewardEligibilityStartup.class, 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(() -> RewardEligibilityStartup.loginSecuritySession(fixture.player)) + .thenAnswer(call -> { assertFalse(fixture.owner.get()); return session; }); + when(session.isLoggedIn()).thenReturn(false); + RewardEligibilityStartup.start(fixture.plugin, fixture.eligibility); + assertTrue(fixture.receipts.isEmpty()); + verifyNoInteractions(fixture.users); + } + } + + @Test void loginSecurityAuthorizedSessionDoesNotRequireAnAdditionalPasswordCheck() { + try (Fixture fixture = new Fixture(); var api = mockStatic(RewardEligibilityStartup.class, 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(() -> RewardEligibilityStartup.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); + RewardEligibilityStartup.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)); + RewardEligibilityStartup.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()); + RewardEligibilityStartup.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()); + RewardEligibilityStartup.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(); + RewardEligibilityStartup.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 = RewardEligibilityStartup.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 = RewardEligibilityStartup.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 = RewardEligibilityStartup.authenticate(fixture.plugin, fixture.eligibility, + fixture.player, "StartupPlayer", false, inFlight); + var next = first.thenCompose(accepted -> RewardEligibilityStartup.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 = RewardEligibilityStartup.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.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 = new RewardEligibility(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/api/rewards/RewardGamemodeTimedTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java index c173d65eaa..26f2dc1841 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java @@ -88,6 +88,48 @@ class RewardGamemodeTimedTest { } } + @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 fixture.handler.getRewardEligibility().queued(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); @@ -591,6 +633,7 @@ private final class Fixture implements AutoCloseable { 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"); @@ -614,6 +657,11 @@ private final class Fixture implements AutoCloseable { user.setData(data); doReturn(player).when(user).getPlayer(); doReturn(true).when(user).isOnline(); + 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) { diff --git a/docs/reward-gamemode-eligibility.md b/docs/reward-gamemode-eligibility.md index 333b7487e2..2fac3e3968 100644 --- a/docs/reward-gamemode-eligibility.md +++ b/docs/reward-gamemode-eligibility.md @@ -56,6 +56,37 @@ accepted. Quit removes only this plugin's receipt; it is session metadata, not p 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. + +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. From bda0f66e92353921393c814bb5994257572db3c8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:34:33 -0600 Subject: [PATCH 4/9] Keep reward requirements off player owner threads Separate native predicates from worker-side providers and preserve accepted-login gates for newly deferred work. --- .../advancedcore/api/rewards/Reward.java | 118 +++++++----- .../api/rewards/RewardEligibility.java | 8 +- .../requirements/RequirementGameMode.java | 3 + .../RequirementLocationDistance.java | 3 + .../requirements/RequirementWorld.java | 26 ++- .../RequirementInject.java | 9 + .../api/rewards/RewardEligibilityTest.java | 44 ++++- .../rewards/RewardGamemodeCommandTest.java | 1 + .../rewards/RewardGamemodeEconomyTest.java | 2 + .../rewards/RewardGamemodeMutationTest.java | 1 + .../api/rewards/RewardGamemodeTimedTest.java | 176 ++++++++++++++++++ .../BuiltinRequirementRegistrationTest.java | 8 + .../rewards/RewardAsyncInjectionTest.java | 23 ++- docs/reward-gamemode-eligibility.md | 17 ++ 14 files changed, 381 insertions(+), 58 deletions(-) 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 56b1025d24..a25e5a8bc4 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 @@ -156,11 +156,31 @@ public boolean canGiveReward(AdvancedCoreUser user, RewardOptions options) { return true; } - /** Evaluates requirements on the player's owning scheduler without blocking the caller. */ + /** 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(); - return requestOnServerThread(user, - () -> CompletableFuture.completedFuture(canGiveReward(user, stableOptions))); + 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 = () -> CompletableFuture.completedFuture( + inject.onRequirementRequest(this, user, getConfig().getConfigData(), options)); + return inject.requiresPlayerThread() ? requestOnServerThread(user, check) : check.get(); + }); } public boolean checkDelayed(AdvancedCoreUser user, HashMap placeholders) { @@ -1657,8 +1677,12 @@ 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))); } /** Deterministic player-context check, invoked only on the owning scheduler. */ @@ -1707,55 +1731,55 @@ private boolean hasApprovedPartialReplayChance(RewardOptions options) { || (options.getAsyncReplayState() != null && options.getAsyncReplayState().highestCompletedCount() > 0); } - private CompletionStage evaluateLiveRewardDecision(AdvancedCoreUser user, - RewardOptions rewardOptions) { - boolean liveOnline = rewardOptions.isLivePlayerStateSet() ? rewardOptions.isOnline() : user.isOnline(); + /** 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 canGive = checkPlayerEligibility(user, rewardOptions); - boolean playerEligibilityDenied = !canGive; - boolean allowOffline = !canGive; - boolean chanceApproved = false; - if (!rewardOptions.isIgnoreRequirements()) { + CompletionStage decision = CompletableFuture.completedFuture(snapshot); + AtomicBoolean chanceApproved = new AtomicBoolean(); + if (!options.isIgnoreRequirements()) { for (RequirementInject inject : plugin.getRewardHandler().getInjectedRequirements()) { - try { - if ("Chance".equals(inject.getPath()) && hasApprovedPartialReplayChance(rewardOptions)) continue; - if (!inject.onRequirementRequest(this, user, getConfig().getConfigData(), rewardOptions)) { - canGive = false; - if (!inject.isAllowReattempt()) { - return CompletableFuture.completedFuture( - new LiveRewardDecision(false, false, vanished, false, true, playerEligibilityDenied)); + decision = decision.thenCompose(current -> { + if (current.terminalDenied() || ("Chance".equals(inject.getPath()) + && hasApprovedPartialReplayChance(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; - } else if ("Chance".equals(inject.getPath())) { - chanceApproved = 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())) chanceApproved.set(true); + return current; + }); + }); } } - if (canGive && chanceApproved && RewardEligibility.isConfigured(getConfig().getConfigData())) { - String key = chanceReplayKey(rewardOptions); - rewardOptions.addPlaceholder(key, "1"); - if (rewardOptions.getAsyncReplayState() != null) - rewardOptions.getAsyncReplayState().recordReplayMetadata(key, "1"); - } - boolean verifyOnline = !rewardOptions.isOnline() || rewardOptions.getServer() != null; - boolean liveOffline = verifyOnline && !liveOnline; - return CompletableFuture.completedFuture( - new LiveRewardDecision(canGive, allowOffline, vanished, liveOffline, false, playerEligibilityDenied)); + return decision.thenApply(current -> { + if (current.canGive() && chanceApproved.get() && RewardEligibility.isConfigured(getConfig().getConfigData())) { + String key = chanceReplayKey(options); + options.addPlaceholder(key, "1"); + if (options.getAsyncReplayState() != null) options.getAsyncReplayState().recordReplayMetadata(key, "1"); + } + return current; + }); } private CompletionStage finishRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions, 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 index d820395c26..50b7f5ec24 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java @@ -123,6 +123,8 @@ public boolean isEligible(ConfigurationSection data, AdvancedCoreUser user) { 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; @@ -155,6 +157,7 @@ void deferred(AdvancedCoreUser user) { 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()); @@ -162,11 +165,12 @@ void deferred(AdvancedCoreUser user) { 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, player, nanoClock.getAsLong()); + current = new Pending(user, accepted ? player : null, nanoClock.getAsLong()); pending.put(user.getUUID(), current); } current.user = user; - current.player = player; + current.player = accepted ? player : null; + current.awaitingLogin = !accepted; current.revision++; startFallback(); } 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 index 29c611d97d..8619925021 100644 --- 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 @@ -26,6 +26,9 @@ public void onValidate(Reward reward, RequirementInject inject, ConfigurationSec } }.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) { 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/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..fb60e3b866 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,15 @@ public boolean isEditable() { public abstract boolean onRequirementRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, RewardOptions rewardOptions); + /** 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/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityTest.java index 05e171aac5..ad655290b3 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityTest.java @@ -61,6 +61,10 @@ void ownerSignalDoesNotWaitForStorageOwnershipMonitor() throws Exception { 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 = new RewardEligibility(plugin, handler); eligibility.deferred(user); try { @@ -367,6 +371,40 @@ void unknownHintDoesNotReadAReplacementFromThePreviousOwnerCallback() { } } + @Test + void missingReceiptCannotAuthenticateAFreshDeferredHint() { + try (SignalFixture fixture = new SignalFixture(false)) { + fixture.eligibility.deferred(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"); + fixture.eligibility.deferred(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()) { @@ -466,7 +504,9 @@ private static final class SignalFixture implements AutoCloseable { final RewardEligibility eligibility; final java.util.concurrent.atomic.AtomicLong clock = new java.util.concurrent.atomic.AtomicLong(); - SignalFixture() { + SignalFixture() { this(true); } + + SignalFixture(boolean acceptedLogin) { when(plugin.isEnabled()).thenReturn(true); when(plugin.getTimer()).thenReturn(worker); when(plugin.getBukkitScheduler()).thenReturn(scheduler); @@ -501,7 +541,7 @@ private static final class SignalFixture implements AutoCloseable { eligibility.queued(user); return java.util.concurrent.CompletableFuture.completedFuture(null); }); - eligibility.signalLogin(user, player).toCompletableFuture().join(); + if (acceptedLogin) eligibility.signalLogin(user, player).toCompletableFuture().join(); } @Override public void close() { eligibility.close(); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java index 40e37810f2..7e3c2d7c54 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeCommandTest.java @@ -150,6 +150,7 @@ private void commandScenario(String kind, boolean policy, boolean disconnect, in 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); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java index 25fa9e3595..25775b3d4f 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeEconomyTest.java @@ -180,6 +180,8 @@ private void economyScenario(boolean withdrawal, String policy, String transitio 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"); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java index e9d0447a37..d7375bfce6 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeMutationTest.java @@ -139,6 +139,7 @@ private void mutationScenario(boolean itemAction, String rule, boolean changeMod 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); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java index 26f2dc1841..ca1c6838ac 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java @@ -43,6 +43,181 @@ class RewardGamemodeTimedTest { @TempDir File directory; + @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 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); @@ -657,6 +832,7 @@ private final class Fixture implements AutoCloseable { 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; 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 294e25926e..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 @@ -111,6 +111,14 @@ public void builtinCatalogRegistersAllRequirementPathsInHistoricalOrder() { "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, String... expectedPaths) { int before = rewardHandler.getInjectedRequirements().size(); 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 index 2fac3e3968..3b4fca13fa 100644 --- a/docs/reward-gamemode-eligibility.md +++ b/docs/reward-gamemode-eligibility.md @@ -104,3 +104,20 @@ 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. From b5dcf580f8594cbba13863bd212e4cafd3042a27 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:15:58 -0600 Subject: [PATCH 5/9] Fix permission requirement thread ownership --- .../bencodez/advancedcore/VaultHandler.java | 24 ++ .../advancedcore/api/misc/PlayerManager.java | 13 + .../api/permissions/LuckPermsHandle.java | 24 ++ .../advancedcore/api/rewards/Reward.java | 19 +- .../requirements/RequirementPermission.java | 30 ++ .../RequirementInject.java | 7 + .../RequirementInjectString.java | 9 +- .../api/rewards/RewardGamemodeTimedTest.java | 271 ++++++++++++++++++ docs/reward-gamemode-eligibility.md | 10 + 9 files changed, 403 insertions(+), 4 deletions(-) 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/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 a25e5a8bc4..15661753a0 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 @@ -177,8 +177,8 @@ private CompletionStage requestRequirementAsync(RequirementInject injec if (!plugin.isEnabled()) return CompletableFuture.failedFuture( new IllegalStateException("Plugin disabled before reward requirement evaluation")); inject.prepareAsyncRequest(this, getConfig().getConfigData(), options); - Supplier> check = () -> CompletableFuture.completedFuture( - inject.onRequirementRequest(this, user, getConfig().getConfigData(), options)); + Supplier> check = () -> + inject.onRequirementRequestAsync(this, user, getConfig().getConfigData(), options); return inject.requiresPlayerThread() ? requestOnServerThread(user, check) : check.get(); }); } @@ -1824,6 +1824,21 @@ private CompletionStage deferRewardWithoutBlockingOwner(AdvancedCoreUser u private record LiveRewardDecision(boolean canGive, boolean allowOffline, boolean vanished, boolean liveOffline, 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<>(); try { 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/injectedrequirement/RequirementInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injectedrequirement/RequirementInject.java index fb60e3b866..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,13 @@ 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; 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/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java index ca1c6838ac..3cf14d1cbd 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java @@ -37,6 +37,7 @@ 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; @@ -153,6 +154,266 @@ class RewardGamemodeTimedTest { } } + @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") { @@ -755,6 +1016,7 @@ private final class Fixture implements AutoCloseable { 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; @@ -817,6 +1079,7 @@ private final class Fixture implements AutoCloseable { 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 RewardEligibility getRewardEligibility() { @@ -868,6 +1131,13 @@ Reward reward(String name, boolean policy) { 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()); @@ -896,6 +1166,7 @@ void drain() throws Exception { 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); diff --git a/docs/reward-gamemode-eligibility.md b/docs/reward-gamemode-eligibility.md index 3b4fca13fa..3501887ec3 100644 --- a/docs/reward-gamemode-eligibility.md +++ b/docs/reward-gamemode-eligibility.md @@ -121,3 +121,13 @@ A freshly deferred hint never authenticates a player. Without this plugin's acce 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. From 23655ca249d5b02238874b5ca5fe7b497b7f2a74 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:43:52 -0600 Subject: [PATCH 6/9] Accept native reward sessions when user data is disabled --- .../api/rewards/RewardEligibility.java | 26 +++++++ .../api/rewards/RewardEligibilityStartup.java | 5 +- .../listeners/RewardEligibilityListener.java | 8 +- .../rewards/RewardEligibilityStartupTest.java | 75 +++++++++++++++++++ .../api/rewards/RewardGamemodeTimedTest.java | 58 ++++++++++++++ docs/reward-gamemode-eligibility.md | 8 ++ 6 files changed, 177 insertions(+), 3 deletions(-) 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 index 50b7f5ec24..21dad217ea 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java @@ -66,6 +66,32 @@ public void loginPending(Player player) { 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 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 index b4a604b5f7..551ab7cba9 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartup.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartup.java @@ -42,7 +42,10 @@ static void start(AdvancedCorePlugin plugin, RewardEligibility eligibility) { private static CompletionStage probe(AdvancedCorePlugin plugin, RewardEligibility eligibility, Player player, AtomicBoolean queryInFlight) { return Reward.continueOnPlayerThread(plugin, player, () -> { - if (!current(plugin, eligibility) || !player.isOnline() || pendingLogin(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(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java index 07fcf46418..45e11c2206 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/RewardEligibilityListener.java @@ -17,8 +17,12 @@ public final class RewardEligibilityListener implements Listener { @EventHandler(priority = EventPriority.LOWEST) public void onNativeJoin(org.bukkit.event.player.PlayerJoinEvent event) { var handler = plugin.getRewardHandler(); - if (plugin.isEnabled() && handler != null) - handler.getRewardEligibility().loginPending(event.getPlayer()); + 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) diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartupTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartupTest.java index 560a459ee4..5b633a55cd 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartupTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardEligibilityStartupTest.java @@ -13,11 +13,14 @@ 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() { @@ -32,6 +35,77 @@ class RewardEligibilityStartupTest { } } + @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"); + RewardEligibilityStartup.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); + RewardEligibilityStartup.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()); + RewardEligibilityStartup.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)); + fixture.eligibility.queued(fixture.user); + fixture.eligibility.signal(fixture.user).toCompletableFuture().join(); + fixture.eligibility.queued(unrelated); + RewardEligibilityStartup.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)); @@ -246,6 +320,7 @@ private static final class Fixture implements AutoCloseable { 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); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java index 3cf14d1cbd..20c9169e1d 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java @@ -44,6 +44,63 @@ 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(); @@ -1025,6 +1082,7 @@ private final class Fixture implements AutoCloseable { 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); diff --git a/docs/reward-gamemode-eligibility.md b/docs/reward-gamemode-eligibility.md index 3501887ec3..9a04564882 100644 --- a/docs/reward-gamemode-eligibility.md +++ b/docs/reward-gamemode-eligibility.md @@ -69,6 +69,14 @@ 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 From 7bff4cbb2738123614cc6768024f62e45985e871 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:32:08 -0600 Subject: [PATCH 7/9] Recheck accepted-login rewards after existing replay claims settle --- .../api/rewards/RewardEligibility.java | 20 +++++++ .../api/user/AdvancedCoreUser.java | 19 +++++++ .../api/rewards/RewardGamemodeTimedTest.java | 57 +++++++++++++++++++ 3 files changed, 96 insertions(+) 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 index 21dad217ea..584b8953d8 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java @@ -320,10 +320,12 @@ private void runSignalCheck(AdvancedCoreUser user, Pending captured, long revisi 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 @@ -343,6 +345,22 @@ private void runSignalCheck(AdvancedCoreUser user, Pending captured, long revisi 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(); @@ -472,6 +490,7 @@ public CompletionStage signalLogin(AdvancedCoreUser user, Player player) { current.user = user; current.player = player; current.awaitingLogin = false; + current.awaitReplaySettlement = true; current.revision++; } } @@ -537,6 +556,7 @@ private static final class Pending { private Player player; private long revision; private boolean awaitingLogin; + private boolean awaitReplaySettlement; private CompletableFuture running; private long fallbackDelayNanos = INITIAL_FALLBACK_NANOS; private long nextSweepNanos; 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 a89828cf29..f09a8a0c79 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 @@ -527,6 +527,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<>(); } /** @@ -1289,6 +1290,23 @@ public boolean hasPendingRewardReplay() { } } + /** + * 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"); @@ -1598,6 +1616,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); } } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java index 20c9169e1d..802dbc923a 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java @@ -33,6 +33,7 @@ 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; @@ -646,6 +647,52 @@ private void fullHintBudgetScenario(boolean protectedTimedHints) throws Exceptio 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); } @@ -1070,6 +1117,9 @@ private final class Fixture implements AutoCloseable { 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); @@ -1140,6 +1190,13 @@ private final class Fixture implements AutoCloseable { 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; } From 67013a88776b27eb4411602cf7ad40a489438479 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:53:17 -0600 Subject: [PATCH 8/9] Keep gamemode reward callbacks on the reward worker --- .../advancedcore/api/rewards/Reward.java | 13 ++- .../api/rewards/RewardGamemodeTimedTest.java | 80 +++++++++++++++++++ docs/reward-gamemode-eligibility.md | 2 + 3 files changed, 93 insertions(+), 2 deletions(-) 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 15661753a0..22534d33f5 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 @@ -1268,8 +1268,17 @@ private CompletionStage invokeInjectionAsync(RewardInject inject, Advanc && !checkPlayerEligibility(user, eligibilityOptions)) { return CompletableFuture.failedFuture(new PlayerEligibilityDeferredException()); } - return requestInjectionAsync(inject, user, placeholders, replayState, injectionKey, occurrenceId, - eligibilityOptions); + 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); + }; + // 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(getConfig().getConfigData()) + ? continueOffServerThread(injection) : injection.get(); }); CompletionStage result = inject.isSynchronize() && inject.supportsAsyncSynchronization() ? inject.runSynchronizedAsync(request) diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java index 802dbc923a..b276356e46 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java @@ -212,6 +212,86 @@ class RewardGamemodeTimedTest { } } + @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 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(); diff --git a/docs/reward-gamemode-eligibility.md b/docs/reward-gamemode-eligibility.md index 9a04564882..8740b7c6fd 100644 --- a/docs/reward-gamemode-eligibility.md +++ b/docs/reward-gamemode-eligibility.md @@ -139,3 +139,5 @@ contract. Custom requirements may override `onRequirementRequestAsync` to compos 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. From f300f130a325a15a07636ada87ad42bc0231916f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:21:19 -0600 Subject: [PATCH 9/9] Preserve resolved player sessions across reward worker handoffs --- .../advancedcore/api/rewards/Reward.java | 12 ++-- .../api/user/AdvancedCoreUser.java | 37 ++++++++-- .../api/rewards/RewardGamemodeTimedTest.java | 68 +++++++++++++++++++ docs/reward-gamemode-eligibility.md | 2 + 4 files changed, 107 insertions(+), 12 deletions(-) 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 22534d33f5..40efb2db54 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 @@ -1263,7 +1263,7 @@ private CompletionStage invokeInjectionAsync(RewardInject inject, Advanc try { if (!plugin.isEnabled()) return CompletableFuture.failedFuture( new IllegalStateException("Plugin disabled before reward injection completed")); - Supplier> request = () -> requestOnServerThread(user, () -> { + Supplier> request = () -> requestOnServerThread(plugin, user, ownerPlayer -> { if (eligibilityOptions != null && !bypassesPlayerEligibility(eligibilityOptions) && !checkPlayerEligibility(user, eligibilityOptions)) { return CompletableFuture.failedFuture(new PlayerEligibilityDeferredException()); @@ -1272,14 +1272,14 @@ private CompletionStage invokeInjectionAsync(RewardInject inject, Advanc if (!plugin.isEnabled()) return CompletableFuture.failedFuture( new IllegalStateException("Plugin disabled before reward injection completed")); return requestInjectionAsync(inject, user, placeholders, replayState, injectionKey, occurrenceId, - eligibilityOptions); + 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(getConfig().getConfigData()) ? continueOffServerThread(injection) : injection.get(); - }); + }, getServerThreadDispatchTimeoutMillis(), null); CompletionStage result = inject.isSynchronize() && inject.supportsAsyncSynchronization() ? inject.runSynchronizedAsync(request) : request.get(); @@ -1385,7 +1385,7 @@ protected long getServerThreadDispatchTimeoutMillis() { private CompletionStage requestInjectionAsync(RewardInject inject, AdvancedCoreUser user, HashMap placeholders, ReplayState replayState, String injectionKey, String occurrenceId, - RewardOptions eligibilityOptions) { + RewardOptions eligibilityOptions, Player ownerPlayer) { ReplayState previous = ACTIVE_REPLAY_STATE.get(); String previousKey = ACTIVE_REPLAY_KEY.get(); String previousOccurrenceId = ACTIVE_REPLAY_OCCURRENCE_ID.get(); @@ -1400,13 +1400,13 @@ private CompletionStage requestInjectionAsync(RewardInject inject, Advan } AdvancedCoreUser.AsyncActionCollection actionCollection = playerEligibility == null ? user.beginAsyncActionCollection(replayState, placeholders, injectionKey) - : user.beginAsyncActionCollection(replayState, placeholders, injectionKey, playerEligibility); + : 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()) { 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 f09a8a0c79..b3902dfde5 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 @@ -96,11 +96,13 @@ public static final class AsyncActionCollection { 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, - Supplier playerEligibility) { + Supplier playerEligibility, Player resolvedPlayer, boolean playerResolved) { this.previous = previous; this.owner = owner; this.replayState = replayState; @@ -109,6 +111,8 @@ private AsyncActionCollection(AsyncActionCollection previous, AdvancedCoreUser o this.snapshotKey = checkpointKey + ACTION_SNAPSHOT_SUFFIX; this.plugin = plugin; this.playerEligibility = playerEligibility; + this.resolvedPlayer = resolvedPlayer; + this.playerResolved = playerResolved; } private boolean isPlayerEligible() { @@ -390,7 +394,17 @@ public AsyncActionCollection beginAsyncActionCollection(Reward.ReplayState repla 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, playerEligibility); + 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; } @@ -409,7 +423,9 @@ public AsyncActionContext captureAsyncActionContext() { public static PlayerEligibilityGuard captureCurrentPlayerEligibilityGuard() { AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); return collection == null || collection.playerEligibility == null ? null - : new PlayerEligibilityGuard(collection.owner, collection.owner.getPlayer(), collection::isPlayerEligible); + : 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. */ @@ -2254,6 +2270,12 @@ private void scheduleLegacyRewardActionAsync(Supplier> act 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(() -> { @@ -2789,11 +2811,12 @@ public CompletionStage preformCommandAsync(ArrayList commands, Has 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, replayState, replayKey, (command, ignoredIndex) -> { - final Player player = getPlayer(); + final Player player = eligibilityGuard == null ? getPlayer() : eligibilityGuard.player; if (player == null || !plugin.isEnabled()) { if (player == null && eligibilityGuard != null && plugin.isEnabled()) { return CompletableFuture.failedFuture(replayActionNotStarted( @@ -2812,7 +2835,9 @@ 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); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java index b276356e46..23b1bff9c2 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardGamemodeTimedTest.java @@ -10,6 +10,7 @@ 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; @@ -270,6 +271,73 @@ private void configuredGameModeRewardInjectionRunsOffOwner(String path, GameMode } } + @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); diff --git a/docs/reward-gamemode-eligibility.md b/docs/reward-gamemode-eligibility.md index 8740b7c6fd..43456aec74 100644 --- a/docs/reward-gamemode-eligibility.md +++ b/docs/reward-gamemode-eligibility.md @@ -141,3 +141,5 @@ native reads through `evaluatePlayerRequirement` with provider work through 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.