From d0fde49ea0b3f1d400d4ec2b634922a12c451c93 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 29 Oct 2026 17:21:57 -0600 Subject: [PATCH 1/2] fix(rewards): guard missing player identities and preserve command replay --- .../advancedcore/api/misc/MiscUtils.java | 132 ++++- .../advancedcore/api/misc/PlayerManager.java | 6 +- .../advancedcore/api/rewards/Reward.java | 28 +- .../api/rewards/RewardOptions.java | 7 + .../api/rewards/builtin/RewardCommands.java | 8 +- .../api/user/AdvancedCoreUser.java | 8 +- .../misc/ConsoleMissingIdentityTest.java | 467 ++++++++++++++++++ .../rewards/RewardAsyncInjectionTest.java | 109 ++++ 8 files changed, 722 insertions(+), 43 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/ConsoleMissingIdentityTest.java 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 511c9abe8c..8cc930a86b 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 @@ -46,6 +46,8 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue; +import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.bukkit.platform.BukkitPlatformServices; @@ -244,16 +246,7 @@ public void run() { public void executeConsoleCommands(final String playerName, final ArrayList cmds, final HashMap placeholders, boolean stagger) { if (cmds != null && !cmds.isEmpty()) { - placeholders.put("player", playerName); - OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); - ArrayList commands = cmds; - if (p != null) { - commands = PlaceholderUtils.replaceJavascriptOnly(p, commands); - } - commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); - if (p != null) { - commands = PlaceholderUtils.replacePlaceHolders(p, commands); - } + ArrayList commands = expandConsoleCommands(playerName, cmds, placeholders, true); int tick = 0; for (final String cmd : commands) { plugin.debug("Executing console command: " + cmd); @@ -264,6 +257,10 @@ public void executeConsoleCommands(final String playerName, final ArrayList placeholders) { + if ((playerName == null || playerName.isBlank()) && !Reward.hasReplayCommandSnapshot(placeholders, "console")) { + expandConsoleCommands(playerName, command == null || command.isEmpty() + ? new ArrayList<>() : new ArrayList<>(java.util.List.of(command)), placeholders, false); + } executeConsoleCommandsAsync(playerName, command, placeholders); } @@ -284,15 +281,8 @@ public CompletionStage executeConsoleCommandsAsync(String playerName, Stri try { ArrayList templates = command == null || command.isEmpty() ? new ArrayList<>() : new ArrayList<>(java.util.List.of(command)); - String expanded = command; - OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); - if (expanded != null && !expanded.isEmpty()) { - if (p != null) expanded = PlaceholderUtils.replaceJavascriptOnly(p, expanded); - expanded = PlaceholderUtils.replacePlaceHolder(expanded, placeholders); - if (p != null) expanded = PlaceholderUtils.replacePlaceHolders(p, expanded); - } - ArrayList commands = command == null || command.isEmpty() - ? new ArrayList<>() : new ArrayList<>(java.util.List.of(expanded == null ? "" : expanded)); + ArrayList commands = Reward.hasReplayCommandSnapshot(placeholders, "console") + ? new ArrayList<>() : expandConsoleCommands(playerName, templates, placeholders, false); return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, (cmd, ignoredIndex) -> { plugin.debug("Executing console command: " + cmd); @@ -319,16 +309,20 @@ public CompletionStage executeConsoleCommandsAsync(final String playerName public CompletionStage executeConsoleCommandsAsync(final String playerName, final ArrayList cmds, final HashMap placeholders, final boolean stagger, AdvancedCoreUser.PlayerEligibilityGuard eligibilityGuard) { + return executeConsoleCommandsAsync(playerName, cmds, placeholders, stagger, eligibilityGuard, + Reward.currentReplayState(), Reward.currentReplayKey()); + } + + /** Carries the replay context across asynchronous availability checks. */ + public CompletionStage executeConsoleCommandsAsync(final String playerName, final ArrayList cmds, + final HashMap placeholders, final boolean stagger, + AdvancedCoreUser.PlayerEligibilityGuard eligibilityGuard, Reward.ReplayState replayState, String replayKey) { try { - placeholders.put("player", playerName); - OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); ArrayList templates = cmds == null ? new ArrayList<>() : new ArrayList<>(cmds); - ArrayList commands = new ArrayList<>(templates); - if (p != null) commands = PlaceholderUtils.replaceJavascriptOnly(p, commands); - commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); - if (p != null) commands = PlaceholderUtils.replacePlaceHolders(p, commands); + ArrayList commands = Reward.hasReplayCommandSnapshot(placeholders, "console", replayState, replayKey) + ? new ArrayList<>() : expandConsoleCommands(playerName, templates, placeholders, true); - return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, + return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, replayState, replayKey, (command, index) -> { plugin.debug("Executing console command: " + command); return runConsoleCommandAsync(stripLeadingSlash(command), index > 0 ? 1 : 0, @@ -339,6 +333,92 @@ public CompletionStage executeConsoleCommandsAsync(final String playerName } } + @SuppressWarnings("deprecation") + private ArrayList expandConsoleCommands(String playerName, List templates, + HashMap placeholders, boolean addPlayer) { + ArrayList commands = new ArrayList<>(templates); + if (commands.isEmpty()) return commands; + boolean hasName = playerName != null && !playerName.isBlank(); + if (hasName) { + if (addPlayer) placeholders.put("player", playerName); + OfflinePlayer player = Bukkit.getOfflinePlayer(playerName); + if (player != null) commands = PlaceholderUtils.replaceJavascriptOnly(player, commands); + commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); + if (player != null) commands = PlaceholderUtils.replacePlaceHolders(player, commands); + return commands; + } + for (String command : commands) { + if (command != null && (containsPlayerNamePlaceholder(command) + || (plugin.getOptions().isJavascriptEngineEnabled() + && containsJavascriptSegment(command)))) { + throw new IllegalStateException("Console command requires an unavailable player identity"); + } + } + // Trace identity aliases through map substitutions before an empty value can + // erase them. Other map entries may themselves introduce an alias. + String marker = "__advancedcore_missing_name_" + java.util.UUID.randomUUID() + "__"; + HashMap guarded = placeholders == null ? new LinkedHashMap<>() : new LinkedHashMap<>(placeholders); + guarded.replaceAll((key, value) -> key != null && (key.equalsIgnoreCase("player") || key.equalsIgnoreCase("@p")) + ? marker : value); + guarded.put("player", marker); + guarded.put("@p", marker); + String encodedMarker = JavascriptPlaceholderValue.encode(marker); + for (String command : PlaceholderUtils.replacePlaceHolder(commands, guarded)) { + if (command != null && (command.contains(marker) || command.contains(encodedMarker) + || containsPlayerNamePlaceholder(command))) { + throw new IllegalStateException("Console command requires an unavailable player identity"); + } + } + commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); + for (String command : commands) { + if (command != null && (containsPlayerNamePlaceholder(command) + || (plugin.isPlaceHolderAPIEnabled() && containsProcessedExternalPlaceholder(command)))) { + throw new IllegalStateException("Console command placeholders require an unavailable player identity"); + } + } + return commands; + } + + private static boolean containsPlayerNamePlaceholder(String command) { + return Pattern.compile("(?i)%(?:player|@p)%|\\{(?:player|@p)\\}").matcher(command).find(); + } + + private static boolean containsJavascriptSegment(String command) { + AtomicBoolean found = new AtomicBoolean(); + JavascriptTextTemplate.parse(command).transform(Function.identity(), script -> { + found.set(true); + return script; + }); + return found.get(); + } + + /** PlaceholderAPI processes literal segments, not authored JavaScript bodies. */ + private static boolean containsProcessedExternalPlaceholder(String command) { + AtomicBoolean found = new AtomicBoolean(); + JavascriptTextTemplate.parse(command).transform(text -> { + if (containsExternalPlaceholder(text)) found.set(true); + return text; + }, Function.identity()); + return found.get(); + } + + /** Matches the percent-delimited expansion boundaries used by PlaceholderAPI. */ + private static boolean containsExternalPlaceholder(String text) { + int start = text.indexOf('%'); + while (start >= 0) { + int end = text.indexOf('%', start + 1); + if (end < 0) return false; + boolean abandoned = false; + for (int index = start + 1; index < end; index++) { + char c = text.charAt(index); + if (c == '_') return true; + if (c == ' ') { abandoned = true; break; } + } + start = text.indexOf('%', abandoned ? start + 1 : end + 1); + } + return false; + } + public Object getBlockMeta(Block block, String str) { for (MetadataValue meta : block.getMetadata(str)) { if (meta.getOwningPlugin().equals(plugin)) { 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 0132ec6554..aa2e67bbde 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 @@ -215,7 +215,7 @@ public boolean hasPermission(Player player, String perm) { * @return true, if successful */ public boolean hasPermission(String playerName, String perm) { - if (playerName == null) { + if (playerName == null || playerName.isBlank()) { return false; } Player player = Bukkit.getPlayer(playerName); @@ -233,7 +233,7 @@ public boolean hasPermission(String playerName, String perm) { * @return true, if successful */ public boolean hasServerPermission(String playerName, String perm) { - if (playerName == null) { + if (playerName == null || playerName.isBlank()) { return false; } Player player = Bukkit.getPlayer(playerName); @@ -258,7 +258,7 @@ public boolean hasServerPermission(UUID playerUUID, String playername, String pe Player player = Bukkit.getPlayer(playerUUID); if (player != null) { return player.hasPermission(perm); - } else { + } else if (playername != null && !playername.isBlank()) { player = Bukkit.getPlayer(playername); if (player != null && playerUUID.equals(player.getUniqueId())) { return player.hasPermission(perm); 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 2ef7d050c3..dcb1042d9b 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 @@ -247,7 +247,7 @@ public String getRewardName() { public void giveInjectedRewards(AdvancedCoreUser user, HashMap placeholders) { if (RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) { giveRewardUserAsync(user, placeholders, new RewardOptions().setCheckTimed(false)).exceptionally(failure -> { - logRewardUserFailure(failure); + logRewardUserFailure(failure, null); return null; }); return; @@ -858,8 +858,12 @@ private static String replaySequenceKey(String prefix, String activeKey, String /** Returns whether the active replay already froze this logical command lane. */ public static boolean hasReplayCommandSnapshot(HashMap placeholders, String lane) { - ReplayState replayState = currentReplayState(); - String activeKey = currentReplayKey(); + return hasReplayCommandSnapshot(placeholders, lane, currentReplayState(), currentReplayKey()); + } + + /** Checks a captured replay context after an asynchronous continuation. */ + public static boolean hasReplayCommandSnapshot(HashMap placeholders, String lane, + ReplayState replayState, String activeKey) { if (replayState == null || activeKey == null) return false; String storageKey = replaySequenceKey(REPLAY_COMMAND_PREFIX, activeKey, lane == null ? "commands" : lane); @@ -1481,8 +1485,9 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { // player owner. Unconfigured legacy definitions retain their established path. if (RewardEligibility.isConfigured(plugin, getConfig().getConfigData()) && (rewardOptions == null || !rewardOptions.isIgnoreRequirements())) { + Consumer observer = rewardOptions == null ? null : rewardOptions.getAsyncFailureObserver(); giveRewardAsync(user, rewardOptions).exceptionally(failure -> { - logRewardUserFailure(failure); + logRewardUserFailure(failure, observer); return null; }); return; @@ -1913,8 +1918,9 @@ private RewardReplayFailure replayFailureWithSnapshot(RewardOptions options, Thr public void giveRewardUser(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { RewardOptions effectiveOptions = rewardOptions == null ? new RewardOptions() : rewardOptions; if (requiresAwaitedDelivery(effectiveOptions)) { + Consumer observer = effectiveOptions.getAsyncFailureObserver(); giveRewardUserAsync(user, phs, effectiveOptions).exceptionally(failure -> { - logRewardUserFailure(failure); + logRewardUserFailure(failure, observer); return null; }); return; @@ -2033,7 +2039,8 @@ private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap Player player = user.getPlayer(); if (player == null) { - player = Bukkit.getPlayer(user.getPlayerName()); + String playerName = user.getPlayerName(); + if (playerName != null && !playerName.isBlank()) player = Bukkit.getPlayer(playerName); } if (player != null || isForceOffline()) { @@ -2066,7 +2073,14 @@ private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap } } - private void logRewardUserFailure(Throwable failure) { + private void logRewardUserFailure(Throwable failure, Consumer observer) { + if (observer != null) { + try { + observer.accept(failure); + } catch (Throwable ignored) { + // Best-effort diagnostics must not suppress or replace the original failure. + } + } if (plugin.getLogger() != null) { plugin.getLogger().log(Level.WARNING, "Failed to give reward " + getRewardName(), failure); } else { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java index c174557e8b..07f5e4a914 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java @@ -100,6 +100,12 @@ public class RewardOptions { @Setter private Consumer asyncReplayCheckpointConsumer; + /** Optional best-effort observer for a legacy fire-and-forget terminal failure. + * Invoked on the completion thread; it must not block or assume Bukkit ownership. */ + @Getter + @Setter + private Consumer asyncFailureObserver; + /** True while replaying an entry that must remain in the timed queue on deferral. */ @Getter @Setter @@ -273,6 +279,7 @@ RewardOptions copyForDispatch() { copy.setAsyncReplayRegistryFingerprints(new HashMap<>(asyncReplayRegistryFingerprints)); copy.setLegacyAsyncReplayCheckpoint(legacyAsyncReplayCheckpoint); copy.setAsyncReplayCheckpointConsumer(asyncReplayCheckpointConsumer); + copy.setAsyncFailureObserver(asyncFailureObserver); copy.setAsyncReplayKey(asyncReplayKey); copy.setAsyncReplayOccurrenceId(asyncReplayOccurrenceId); copy.setTimedQueueReplay(timedQueueReplay); 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 3a48c82721..437d212e30 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 @@ -261,14 +261,14 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor // Validate the player before any mixed-section side effect, then preserve // the established console-before-player command order. return availability.thenCompose(ignored -> consoleCommands.isEmpty() - && !Reward.hasReplayCommandSnapshot(placeholders, "console") + && !Reward.hasReplayCommandSnapshot(placeholders, "console", replayState, replayKey) ? CompletableFuture.completedFuture(null) - : eligibilityGuard == null + : replayState == null && eligibilityGuard == null ? MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, placeholders, stagger) : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, - placeholders, stagger, eligibilityGuard)) - .thenCompose(ignored -> eligibilityGuard == null + placeholders, stagger, eligibilityGuard, replayState, replayKey)) + .thenCompose(ignored -> replayState == null && eligibilityGuard == null ? user.preformCommandAsync(userCommands, placeholders) : user.preformCommandAsync(userCommands, placeholders, eligibilityGuard, replayState, replayKey)) .thenApply(ignored -> null); 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 ad41b062f8..d404b71fd5 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 @@ -1928,11 +1928,12 @@ public int getNumberOfDaysSinceLogin() { */ @SuppressWarnings("deprecation") public OfflinePlayer getOfflinePlayer() { - if (getPlayerName().isBlank()) { + String name = getPlayerName(); + if (name == null || name.isBlank()) { return null; } if (!plugin.getOptions().isOnlineMode()) { - return Bukkit.getOfflinePlayer(getPlayerName()); + return Bukkit.getOfflinePlayer(name); } if (uuid != null && !uuid.equals("")) { return Bukkit.getOfflinePlayer(java.util.UUID.fromString(uuid)); @@ -1956,7 +1957,8 @@ public ArrayList getOfflineRewards() { */ public Player getPlayer() { if (!plugin.getOptions().isOnlineMode()) { - return Bukkit.getPlayer(getPlayerName()); + String name = getPlayerName(); + return name == null || name.isBlank() ? null : Bukkit.getPlayer(name); } if (uuid != null && !uuid.isEmpty()) { return Bukkit.getPlayer(java.util.UUID.fromString(uuid)); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/ConsoleMissingIdentityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/ConsoleMissingIdentityTest.java new file mode 100644 index 0000000000..04bcc4a928 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/ConsoleMissingIdentityTest.java @@ -0,0 +1,467 @@ +package com.bencodez.advancedcore.tests.misc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletionException; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.command.ConsoleCommandSender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.misc.MiscUtils; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.tests.BaseTest; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class ConsoleMissingIdentityTest { + private AdvancedCorePlugin plugin; + private MiscUtils misc; + private Server server; + private ConsoleCommandSender console; + @BeforeEach void setup() throws Exception { + plugin = BaseTest.getInstance().plugin; + misc = MiscUtils.getInstance(); + Field field = MiscUtils.class.getDeclaredField("plugin"); + field.setAccessible(true); + field.set(misc, plugin); + BukkitScheduler scheduler = mock(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).runTask(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTaskLater(eq(plugin), any(Runnable.class), anyLong()); + server = mock(Server.class); + console = mock(ConsoleCommandSender.class); + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(false); + when(BaseTest.getInstance().options.isJavascriptEngineEnabled()).thenReturn(false); + } + @Test void literalSingleAndListDoNotLookUpMissingIdentity() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + HashMap values = new HashMap<>(); values.put("value", "hello"); + misc.executeConsoleCommandsAsync("", "say %value%", values).toCompletableFuture().join(); + misc.executeConsoleCommandsAsync(null, new ArrayList<>(List.of("say one", "say two")), values, true) + .toCompletableFuture().join(); + verify(server).dispatchCommand(console, "say hello"); + verify(server).dispatchCommand(console, "say one"); + verify(server).dispatchCommand(console, "say two"); + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } + } + @Test void playerAndEnabledExternalOrScriptTokensFailWithoutDispatch() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync(" ", "give %player% stone", new HashMap<>()).toCompletableFuture().join()); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync(null, new ArrayList<>(List.of("give {player} stone")), new HashMap<>(), true).toCompletableFuture().join()); + assertThrows(IllegalStateException.class, () -> misc.executeConsoleCommands("", "give %player% stone", new HashMap<>())); + assertThrows(IllegalStateException.class, () -> misc.executeConsoleCommands("", new ArrayList<>(List.of("give %player% stone")), new HashMap<>(), true)); + for (String alias : List.of("{@p}", "%@p%", "{@P}", "%@P%")) { + HashMap builtins = new HashMap<>(); builtins.put("@p", ""); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "give " + alias + " stone", builtins).toCompletableFuture().join()); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", new ArrayList<>(List.of("give " + alias + " stone")), builtins, true).toCompletableFuture().join()); + assertThrows(IllegalStateException.class, () -> misc.executeConsoleCommands("", "give " + alias + " stone", builtins)); + assertThrows(IllegalStateException.class, () -> misc.executeConsoleCommands("", new ArrayList<>(List.of("give " + alias + " stone")), builtins, true)); + } + for (boolean aliasFirst : List.of(false, true)) { + HashMap mapped = new java.util.LinkedHashMap<>(); + if (aliasFirst) mapped.put("@p", ""); + mapped.put("target", "{@p}"); + if (!aliasFirst) mapped.put("@p", ""); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "give %target% stone", mapped).toCompletableFuture().join()); + } + for (HashMap mapped : List.of(new HashMap(), new java.util.LinkedHashMap())) { + mapped.put("targetName", "%name%"); + mapped.put("name", "%player%"); + mapped.put("player", ""); + assertEquals("give stone", com.bencodez.advancedcore.api.messages.PlaceholderUtils + .replacePlaceHolder("give %targetName% stone", mapped)); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "give %targetName% stone", mapped).toCompletableFuture().join()); + } + HashMap injected = new HashMap<>(); injected.put("target", "%player%"); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "say %target%", injected).toCompletableFuture().join()); + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(true); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "say %some_token%", new HashMap<>()).toCompletableFuture().join()); + injected.put("target", "%some_token%"); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "say %target%", injected).toCompletableFuture().join()); + when(BaseTest.getInstance().options.isJavascriptEngineEnabled()).thenReturn(true); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "say [Javascript=player.getName()]", new HashMap<>()).toCompletableFuture().join()); + for (String script : List.of("say [Javaſcript=player.getName()]", "say [Javascrıpt=player.getName()]")) { + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", script, new HashMap<>()).toCompletableFuture().join()); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", new ArrayList<>(List.of(script)), new HashMap<>(), true).toCompletableFuture().join()); + } + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + verifyNoInteractions(server); + } + } + @Test void missingNameUsesActualPlaceholderApiTokenBoundaries() { + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(true); + var replacer = new me.clip.placeholderapi.replacer.CharsReplacer( + me.clip.placeholderapi.replacer.Replacer.Closure.PERCENT); + List commands = List.of("say 50% complete, 100% ready", "say %literal%", "say %%foo_bar%", + "say %foo_bar%", "say %foo_bar with spaces%", "say \\%foo_bar%", "say %foo bar_baz%", + "say %foo bar_baz%foo_bar%", "say %foo_", "say %_x%", "say %foo\t_bar%", + "say [Javascript='%some_token%']", "say %foo[Javascript=x]_bar%", + "say [Javascript=x] %some_token%", "say %some_token% [Javascript=x]"); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + for (String command : commands) { + clearInvocations(server); + var called = new java.util.concurrent.atomic.AtomicBoolean(); + com.bencodez.advancedcore.api.messages.PlaceholderUtils.replaceExternalPlaceholders(command, + text -> replacer.apply(text, null, ignored -> { called.set(true); return null; }), false); + if (called.get()) { + assertThrows(CompletionException.class, + () -> misc.executeConsoleCommandsAsync("", command, new HashMap<>()).toCompletableFuture().join(), command); + verifyNoInteractions(server); + } else { + misc.executeConsoleCommandsAsync("", command, new HashMap<>()).toCompletableFuture().join(); + verify(server).dispatchCommand(console, command); + } + } + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } + } + @Test void mappedTextHonorsEnabledAndDisabledExpansionBoundaries() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + HashMap values = new HashMap<>(); + values.put("value", "%external_token%"); + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(false); + misc.executeConsoleCommandsAsync("", "say %value%", values).toCompletableFuture().join(); + verify(server).dispatchCommand(console, "say %external_token%"); + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(true); + assertThrows(CompletionException.class, + () -> misc.executeConsoleCommandsAsync("", "say %value%", values).toCompletableFuture().join()); + values.put("value", "[Javascript=1+1]"); + when(BaseTest.getInstance().options.isJavascriptEngineEnabled()).thenReturn(true); + misc.executeConsoleCommandsAsync("", "say %value%", values).toCompletableFuture().join(); + verify(server).dispatchCommand(console, "say [Javascript =1+1]"); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", + "say [Javascript=1+1]", new HashMap<>()).toCompletableFuture().join()); + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } + } + @Test void scriptResultCannotCreateASecondExecutableSegment() { + var offline = mock(OfflinePlayer.class); + when(offline.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + when(BaseTest.getInstance().options.isJavascriptEngineEnabled()).thenReturn(true); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + var engines = mockConstruction(com.bencodez.advancedcore.api.javascript.JavascriptEngine.class, + (engine, context) -> { + when(engine.addPlayer(any(OfflinePlayer.class))).thenReturn(engine); + when(engine.getStringValue(anyString())).thenReturn("[Javascript=2+2]"); + })) { + bukkit.when(() -> Bukkit.getOfflinePlayer("Known")).thenReturn(offline); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + misc.executeConsoleCommandsAsync("Known", "say [Javascript=1+1]", new HashMap<>()).toCompletableFuture().join(); + verify(server).dispatchCommand(console, "say [Javascript =2+2]"); + assertEquals(1, engines.constructed().size()); + verify(engines.constructed().get(0)).getStringValue("1+1"); + } + } + @Test void synchronousAdaptersDispatchLiteralCommandsWithoutAName() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + misc.executeConsoleCommands("", "say single", new HashMap<>()); + misc.executeConsoleCommands((String) null, new ArrayList<>(List.of("say list")), new HashMap<>(), true); + verify(server).dispatchCommand(console, "say single"); + verify(server).dispatchCommand(console, "say list"); + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } + } + @Test void offlineModeMissingNameNeverSelectsAnUnrelatedPlayerForMixedRewards() throws Exception { + when(BaseTest.getInstance().options.isOnlineMode()).thenReturn(true); + when(plugin.isEnabled()).thenReturn(true); + var user = new com.bencodez.advancedcore.api.user.AdvancedCoreUser(plugin, java.util.UUID.randomUUID(), ""); + when(BaseTest.getInstance().options.isOnlineMode()).thenReturn(false); + var unrelated = mock(org.bukkit.entity.Player.class); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommands(handler, plugin); + var sectionHandler = (com.bencodez.advancedcore.api.rewards.injected.RewardInjectConfigurationSection) injections.get(1); + var section = new org.bukkit.configuration.file.YamlConfiguration(); + section.set("Console", new ArrayList<>(List.of("say console"))); + section.set("Player", new ArrayList<>(List.of("say player"))); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer("")).thenReturn(unrelated); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + assertNull(user.getPlayer()); + assertNull(user.getOfflinePlayer()); + var unnamed = spy(user); + doReturn(null).when(unnamed).getPlayerName(); + assertNull(unnamed.getPlayer()); + assertNull(unnamed.getOfflinePlayer()); + assertThrows(CompletionException.class, () -> sectionHandler + .onRewardRequestedAsync(null, user, section, new HashMap<>()).toCompletableFuture().join()); + verifyNoInteractions(server, unrelated); + bukkit.verify(() -> Bukkit.getPlayer(""), never()); + bukkit.verify(() -> Bukkit.getOfflinePlayer(""), never()); + bukkit.verify(() -> Bukkit.getPlayer((String) null), never()); + bukkit.verify(() -> Bukkit.getOfflinePlayer((String) null), never()); + var reward = new Reward("test", new org.bukkit.configuration.file.YamlConfiguration()); + var prepare = Reward.class.getDeclaredMethod("prepareRewardUser", + com.bencodez.advancedcore.api.user.AdvancedCoreUser.class, HashMap.class); + prepare.setAccessible(true); + assertNull(prepare.invoke(reward, user, new HashMap<>())); + bukkit.verify(() -> Bukkit.getPlayer(""), never()); + } + } + @Test void playerLookupPreservesValidNameAndUuidPaths() { + var id = java.util.UUID.randomUUID(); + when(BaseTest.getInstance().options.isOnlineMode()).thenReturn(true); + var user = new com.bencodez.advancedcore.api.user.AdvancedCoreUser(plugin, id, "Known"); + var player = mock(org.bukkit.entity.Player.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + when(BaseTest.getInstance().options.isOnlineMode()).thenReturn(false); + bukkit.when(() -> Bukkit.getPlayer("Known")).thenReturn(player); + assertSame(player, user.getPlayer()); + when(BaseTest.getInstance().options.isOnlineMode()).thenReturn(true); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + assertSame(player, user.getPlayer()); + } + } + @Test void permissionFallbackNeverPerformsBlankNameLookup() { + var manager = com.bencodez.advancedcore.api.misc.PlayerManager.getInstance(); + manager.setPlugin(plugin); + var id = java.util.UUID.randomUUID(); + var unrelated = mock(org.bukkit.entity.Player.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer("")).thenReturn(unrelated); + assertFalse(manager.hasPermission("", "test")); + assertFalse(manager.hasServerPermission(" ", "test")); + assertFalse(manager.hasServerPermission(id, "", "test")); + bukkit.verify(() -> Bukkit.getPlayer(""), never()); + bukkit.verify(() -> Bukkit.getPlayer(" "), never()); + verifyNoInteractions(unrelated); + } + } + @Test void validNamesStillExpandForOfflineAndOnlinePlayers() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + OfflinePlayer offline = mock(OfflinePlayer.class); + OfflinePlayer online = mock(OfflinePlayer.class); + org.bukkit.entity.Player live = mock(org.bukkit.entity.Player.class); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + when(offline.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + when(live.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + when(BaseTest.getInstance().options.isJavascriptEngineEnabled()).thenReturn(true); + when(online.isOnline()).thenReturn(true); + when(online.getPlayer()).thenReturn(live); + bukkit.when(() -> Bukkit.getOfflinePlayer("Offline")).thenReturn(offline); + bukkit.when(() -> Bukkit.getOfflinePlayer("Online")).thenReturn(online); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + misc.executeConsoleCommandsAsync("Offline", new ArrayList<>(List.of("say %player%")), new HashMap<>(), false).toCompletableFuture().join(); + HashMap values = new HashMap<>(); values.put("player", "Online"); + misc.executeConsoleCommandsAsync("Online", "say %player%", values).toCompletableFuture().join(); + verify(server).dispatchCommand(console, "say Offline"); + verify(server).dispatchCommand(console, "say Online"); + verify(offline, atLeastOnce()).isOnline(); + verify(online).getPlayer(); + } + } + @Test void capturedSnapshotSkipsCompletedPrefixWithMissingName() throws Exception { + var ctor = Reward.ReplayState.class.getDeclaredConstructor(Map.class, Map.class, boolean.class); + ctor.setAccessible(true); + Reward.ReplayState state = ctor.newInstance(new HashMap<>(), new HashMap<>(), false); + HashMap values = new HashMap<>(); + List dispatched = new ArrayList<>(); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getOfflinePlayer("Known")).thenReturn(null); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + doAnswer(call -> { String command = call.getArgument(1); dispatched.add(command); + if (command.equals("second") && dispatched.size() == 2) throw new IllegalStateException("temporary"); + return true; }).when(server).dispatchCommand(eq(console), anyString()); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("Known", new ArrayList<>(List.of("first", "second")), values, false, null, state, "reward/0").toCompletableFuture().join()); + misc.executeConsoleCommandsAsync("", new ArrayList<>(List.of("changed %player%")), values, false, null, state, "reward/0").toCompletableFuture().join(); + assertEquals(List.of("first", "second", "second"), dispatched); + bukkit.verify(() -> Bukkit.getOfflinePlayer(""), never()); + } + } + @Test void configuredCommandFormsDispatchLiteralsAndRejectPlayerTargets() { + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.register(handler, plugin); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(user.getPlayerName()).thenReturn(""); + when(user.preformCommandAsync(any(), any())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + var reward = mock(Reward.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + var number = new org.bukkit.configuration.file.YamlConfiguration(); + number.set("NumberCommand.Min", 1); number.set("NumberCommand.Max", 2); + number.set("NumberCommand.Command", "say number"); + var single = new org.bukkit.configuration.file.YamlConfiguration(); single.set("Command", "say single"); + var list = new org.bukkit.configuration.file.YamlConfiguration(); list.set("Commands", new ArrayList<>(List.of("say list"))); + var mixed = new org.bukkit.configuration.file.YamlConfiguration(); mixed.set("Commands.Console", new ArrayList<>(List.of("say mixed"))); + var random = new org.bukkit.configuration.file.YamlConfiguration(); random.set("RandomCommand", new ArrayList<>(List.of("say random"))); + var configs = List.of(number, single, list, mixed, random); + for (int i = 0; i < injections.size(); i++) { + injections.get(i).onRewardRequestAsync(reward, user, configs.get(i), new HashMap<>()).toCompletableFuture().join(); + } + for (String command : List.of("say number", "say single", "say list", "say mixed", "say random")) { + verify(server).dispatchCommand(console, command); + } + number.set("NumberCommand.Command", "give %player% stone"); + single.set("Command", "give %player% stone"); + list.set("Commands", new ArrayList<>(List.of("give %player% stone"))); + mixed.set("Commands.Console", new ArrayList<>(List.of("give %player% stone"))); + random.set("RandomCommand", new ArrayList<>(List.of("give %player% stone"))); + for (int i = 0; i < injections.size(); i++) { + int index = i; + assertThrows(CompletionException.class, () -> injections.get(index) + .onRewardRequestAsync(reward, user, configs.get(index), new HashMap<>()).toCompletableFuture().join()); + } + number.set("NumberCommand.Command", "give {@p} stone"); + single.set("Command", "give {@p} stone"); + list.set("Commands", new ArrayList<>(List.of("give {@p} stone"))); + mixed.set("Commands.Console", new ArrayList<>(List.of("give {@p} stone"))); + random.set("RandomCommand", new ArrayList<>(List.of("give {@p} stone"))); + for (int i = 0; i < injections.size(); i++) { + int index = i; + HashMap builtins = new HashMap<>(); builtins.put("@p", ""); + assertThrows(CompletionException.class, () -> injections.get(index) + .onRewardRequestAsync(reward, user, configs.get(index), builtins).toCompletableFuture().join()); + } + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + verify(server, never()).dispatchCommand(eq(console), contains("%player%")); + } + } + @Test void configuredListReplayResumesOnlyFailedCommandWithMissingIdentity() throws Exception { + var ctor = Reward.ReplayState.class.getDeclaredConstructor(Map.class, Map.class, boolean.class); + ctor.setAccessible(true); + Reward.ReplayState state = ctor.newInstance(new HashMap<>(), new HashMap<>(), false); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommands(handler, plugin); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(user.getPlayerName()).thenReturn("Known", ""); + var config = new org.bukkit.configuration.file.YamlConfiguration(); + config.set("Commands", new ArrayList<>(List.of("first", "second"))); + var placeholders = new HashMap(); + var reward = mock(Reward.class); + List dispatches = new ArrayList<>(); + Field stateField = Reward.class.getDeclaredField("ACTIVE_REPLAY_STATE"); stateField.setAccessible(true); + Field keyField = Reward.class.getDeclaredField("ACTIVE_REPLAY_KEY"); keyField.setAccessible(true); + @SuppressWarnings("unchecked") ThreadLocal stateLocal = (ThreadLocal) stateField.get(null); + @SuppressWarnings("unchecked") ThreadLocal keyLocal = (ThreadLocal) keyField.get(null); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getOfflinePlayer("Known")).thenReturn(null); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + doAnswer(call -> { String command = call.getArgument(1); dispatches.add(command); + if (command.equals("second") && dispatches.size() == 2) throw new IllegalStateException("temporary"); + return true; }).when(server).dispatchCommand(eq(console), anyString()); + stateLocal.set(state); keyLocal.set("reward/0"); + assertThrows(CompletionException.class, () -> injections.get(0) + .onRewardRequestAsync(reward, user, config, placeholders).toCompletableFuture().join()); + config.set("Commands", new ArrayList<>(List.of("changed %player%"))); + injections.get(0).onRewardRequestAsync(reward, user, config, placeholders).toCompletableFuture().join(); + assertEquals(List.of("first", "second", "second"), dispatches); + bukkit.verify(() -> Bukkit.getOfflinePlayer(""), never()); + } finally { stateLocal.remove(); keyLocal.remove(); } + } + @Test void configuredSingleRandomAndLegacySectionUseSavedCommandsWithoutIdentity() throws Exception { + var ctor = Reward.ReplayState.class.getDeclaredConstructor(Map.class, Map.class, boolean.class); + ctor.setAccessible(true); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.register(handler, plugin); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(user.getPlayerName()).thenReturn(""); + when(user.preformCommandAsync(any(), any(), isNull(), any(), anyString())) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + var reward = mock(Reward.class); + Field stateField = Reward.class.getDeclaredField("ACTIVE_REPLAY_STATE"); stateField.setAccessible(true); + Field keyField = Reward.class.getDeclaredField("ACTIVE_REPLAY_KEY"); keyField.setAccessible(true); + @SuppressWarnings("unchecked") ThreadLocal stateLocal = (ThreadLocal) stateField.get(null); + @SuppressWarnings("unchecked") ThreadLocal keyLocal = (ThreadLocal) keyField.get(null); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + for (int index : List.of(1, 3, 4)) { + Reward.ReplayState state = ctor.newInstance(new HashMap<>(), new HashMap<>(), false); + HashMap values = new HashMap<>(); + assertThrows(CompletionException.class, () -> Reward.replayCommandSequence(plugin, values, "console", + List.of("saved"), List.of("saved"), state, "reward/0", + (command, position) -> java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("temporary"))) + .toCompletableFuture().join()); + var config = new org.bukkit.configuration.file.YamlConfiguration(); + if (index == 1) config.set("Command", "give %player% stone"); + if (index == 3) config.set("Commands.Console", new ArrayList<>(List.of("give %player% stone"))); + if (index == 4) config.set("RandomCommand", new ArrayList<>(List.of("give %player% stone"))); + stateLocal.set(state); keyLocal.set("reward/0"); + injections.get(index).onRewardRequestAsync(reward, user, config, values).toCompletableFuture().join(); + stateLocal.remove(); keyLocal.remove(); + } + verify(server, times(3)).dispatchCommand(console, "saved"); + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } finally { stateLocal.remove(); keyLocal.remove(); } + } + @Test void mixedSectionCarriesNullGuardAndReplayAcrossAvailabilityThread() throws Exception { + var ctor = Reward.ReplayState.class.getDeclaredConstructor(Map.class, Map.class, boolean.class); + ctor.setAccessible(true); + Reward.ReplayState state = ctor.newInstance(new HashMap<>(), new HashMap<>(), false); + HashMap values = new HashMap<>(); + assertThrows(CompletionException.class, () -> Reward.replayCommandSequence(plugin, values, "console", + List.of("first", "second"), List.of("first", "second"), state, "reward/0", + (command, index) -> index == 1 + ? java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("temporary")) + : java.util.concurrent.CompletableFuture.completedFuture(null)).toCompletableFuture().join()); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommands(handler, plugin); + var sectionHandler = (com.bencodez.advancedcore.api.rewards.injected.RewardInjectConfigurationSection) injections.get(1); + var section = new org.bukkit.configuration.file.YamlConfiguration(); + section.set("Player", new ArrayList<>(List.of("player-command"))); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var available = new java.util.concurrent.CompletableFuture(); + when(user.validatePlayerCommandAvailabilityAsync()).thenReturn(available); + when(user.getPlayerName()).thenReturn(""); + when(user.preformCommandAsync(any(), any(), isNull(), same(state), eq("reward/0"))) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + MiscUtils fake = mock(MiscUtils.class); + when(fake.executeConsoleCommandsAsync(eq(""), any(ArrayList.class), same(values), eq(true), isNull(), same(state), eq("reward/0"))) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + Field instance = MiscUtils.class.getDeclaredField("instance"); instance.setAccessible(true); + Object old = instance.get(null); + Field stateField = Reward.class.getDeclaredField("ACTIVE_REPLAY_STATE"); stateField.setAccessible(true); + Field keyField = Reward.class.getDeclaredField("ACTIVE_REPLAY_KEY"); keyField.setAccessible(true); + @SuppressWarnings("unchecked") ThreadLocal stateLocal = (ThreadLocal) stateField.get(null); + @SuppressWarnings("unchecked") ThreadLocal keyLocal = (ThreadLocal) keyField.get(null); + try { + instance.set(null, fake); stateLocal.set(state); keyLocal.set("reward/0"); + var result = sectionHandler.onRewardRequestedAsync(null, user, section, values); + stateLocal.remove(); keyLocal.remove(); + java.util.concurrent.CompletableFuture.runAsync(() -> available.complete(null)).get(5, java.util.concurrent.TimeUnit.SECONDS); + result.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + verify(fake).executeConsoleCommandsAsync(eq(""), any(ArrayList.class), same(values), eq(true), isNull(), same(state), eq("reward/0")); + verify(user).preformCommandAsync(any(), same(values), isNull(), same(state), eq("reward/0")); + } finally { instance.set(null, old); stateLocal.remove(); keyLocal.remove(); } + } +} 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 88f295549b..717ea9635c 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 @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -146,6 +147,114 @@ void tearDown() { AdvancedCorePlugin.setInstance(null); } + @Test + void legacyFailureObserverRunsOnceForEligibilityBranchAndDoesNotMaskFailure() { + data.set("GameModes", List.of("SURVIVAL")); + Reward target = org.mockito.Mockito.spy(reward); + RuntimeException original = new RuntimeException("original"); + RewardOptions options = new RewardOptions(); + AtomicInteger calls = new AtomicInteger(); + AtomicReference seen = new AtomicReference<>(); + options.setAsyncFailureObserver(failure -> { calls.incrementAndGet(); seen.set(failure); throw new IllegalStateException("observer"); }); + doReturn(CompletableFuture.failedFuture(original)).when(target).giveRewardAsync(user, options); + target.giveReward(user, options); + assertEquals(1, calls.get()); + assertSame(original, seen.get()); + verify(plugin.getLogger()).log(eq(java.util.logging.Level.WARNING), any(String.class), eq(original)); + } + + @Test + void legacyInjectionFailureObserverDoesNotChangeExplicitAsyncFailure() { + RewardInject async = mock(RewardInject.class); + when(async.supportsAsyncRequest()).thenReturn(true); + handler.getInjectedRewards().add(async); + Reward target = org.mockito.Mockito.spy(reward); + RuntimeException original = new RuntimeException("original"); + RewardOptions options = new RewardOptions(); + AtomicInteger calls = new AtomicInteger(); + options.setAsyncFailureObserver(failure -> { assertSame(original, failure); calls.incrementAndGet(); }); + doReturn(CompletableFuture.failedFuture(original)).when(target).giveRewardUserAsync(eq(user), any(HashMap.class), eq(options)); + assertThrows(CompletionException.class, () -> target.giveRewardUserAsync(user, new HashMap<>(), options).toCompletableFuture().join()); + assertEquals(0, calls.get()); + target.giveRewardUser(user, new HashMap<>(), options); + assertEquals(1, calls.get()); + verify(plugin.getLogger()).log(eq(java.util.logging.Level.WARNING), any(String.class), eq(original)); + } + + @Test + void legacyNestedAsyncFailureNotifiesOnlyAtParentTerminalBoundary() { + RewardInject async = mock(RewardInject.class); + when(async.supportsAsyncRequest()).thenReturn(true); + handler.getInjectedRewards().add(async); + Reward target = org.mockito.Mockito.spy(reward); + Reward child = mock(Reward.class); + RuntimeException original = new RuntimeException("nested"); + RewardOptions options = new RewardOptions(); AtomicInteger calls = new AtomicInteger(); + options.setAsyncFailureObserver(failure -> { assertSame(original, failure); calls.incrementAndGet(); }); + when(child.giveRewardAsync(user, options)).thenReturn(CompletableFuture.failedFuture(original)); + doAnswer(invocation -> child.giveRewardAsync(user, options)) + .when(target).giveRewardUserAsync(eq(user), any(HashMap.class), eq(options)); + target.giveRewardUser(user, new HashMap<>(), options); + verify(child).giveRewardAsync(user, options); + assertEquals(1, calls.get()); + verify(plugin.getLogger()).log(eq(java.util.logging.Level.WARNING), any(String.class), eq(original)); + } + + @Test + void pendingLegacyFailuresKeepTheObserverCapturedAtDispatch() { + RewardInject async = mock(RewardInject.class); + when(async.supportsAsyncRequest()).thenReturn(true); + handler.getInjectedRewards().add(async); + Reward target = org.mockito.Mockito.spy(reward); + RewardOptions options = new RewardOptions(); + AtomicInteger originalCalls = new AtomicInteger(), replacementCalls = new AtomicInteger(); + options.setAsyncFailureObserver(failure -> originalCalls.incrementAndGet()); + CompletableFuture pending = new CompletableFuture<>(); + doReturn(pending).when(target).giveRewardUserAsync(eq(user), any(HashMap.class), eq(options)); + target.giveRewardUser(user, new HashMap<>(), options); + options.setAsyncFailureObserver(failure -> replacementCalls.incrementAndGet()); + pending.completeExceptionally(new IllegalStateException("failure")); + assertEquals(1, originalCalls.get()); + assertEquals(0, replacementCalls.get()); + } + + @Test + void failureObserverSurvivesDispatchAndNestedOptionCopies() throws Exception { + RewardOptions options = new RewardOptions(); + java.util.function.Consumer observer = failure -> { }; + options.setAsyncFailureObserver(observer); + var dispatch = RewardOptions.class.getDeclaredMethod("copyForDispatch"); dispatch.setAccessible(true); + var nested = RewardOptions.class.getDeclaredMethod("copyForNestedDispatch", String.class); nested.setAccessible(true); + RewardOptions dispatchCopy = (RewardOptions) dispatch.invoke(options); + RewardOptions nestedCopy = (RewardOptions) nested.invoke(options, "parent/child"); + assertSame(observer, dispatchCopy.getAsyncFailureObserver()); + assertSame(observer, nestedCopy.getAsyncFailureObserver()); + assertNull(new RewardOptions().getAsyncFailureObserver()); + } + + @Test + void legacyListFanoutNotifiesOncePerConcreteFailure() { + data.set("GameModes", List.of("SURVIVAL")); + Reward first = org.mockito.Mockito.spy(reward); + Reward second = org.mockito.Mockito.spy(new Reward("Second", data)); + var firstFailure = new IllegalStateException("first"); + var secondFailure = new IllegalStateException("second"); + doReturn(CompletableFuture.failedFuture(firstFailure)).when(first).giveRewardAsync(eq(user), any(RewardOptions.class)); + doReturn(CompletableFuture.failedFuture(secondFailure)).when(second).giveRewardAsync(eq(user), any(RewardOptions.class)); + RewardHandler lookup = mock(RewardHandler.class); + when(lookup.getReward("First")).thenReturn(first); + when(lookup.getReward("Second")).thenReturn(second); + var executor = new com.bencodez.advancedcore.api.rewards.RewardExecutor(lookup, plugin); + var config = new YamlConfiguration(); config.set("Rewards", new ArrayList<>(List.of("First", "Second"))); + RewardOptions options = new RewardOptions(); List failures = new ArrayList<>(); + options.setAsyncFailureObserver(failures::add); + try (var bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + executor.giveReward(user, config, "Rewards", options); + } + assertEquals(List.of(firstFailure, secondFailure), failures); + } + @Test void reusedTopLevelOptionsDoNotShareCompletedReplayState() { AtomicInteger invocations = new AtomicInteger(); From 45a65a9806ba7bea0f7207324a4d7e50bf5eb5f5 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 29 Oct 2026 18:08:52 -0600 Subject: [PATCH 2/2] fix(rewards): keep failure observers scoped to their dispatch --- .../advancedcore/api/rewards/Reward.java | 13 +- .../api/rewards/RewardOptions.java | 39 ++++++ .../rewards/RewardAsyncInjectionTest.java | 120 +++++++++++++++++- 3 files changed, 167 insertions(+), 5 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 dcb1042d9b..7e50fa1d4e 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 @@ -1485,9 +1485,10 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { // player owner. Unconfigured legacy definitions retain their established path. if (RewardEligibility.isConfigured(plugin, getConfig().getConfigData()) && (rewardOptions == null || !rewardOptions.isIgnoreRequirements())) { - Consumer observer = rewardOptions == null ? null : rewardOptions.getAsyncFailureObserver(); - giveRewardAsync(user, rewardOptions).exceptionally(failure -> { - logRewardUserFailure(failure, observer); + RewardOptions tracked = (rewardOptions == null ? new RewardOptions() : rewardOptions) + .captureLegacyFailureObserver(); + giveRewardAsync(user, tracked).exceptionally(failure -> { + logRewardUserFailure(failure, tracked.legacyFailureObserverCapture().get()); return null; }); return; @@ -1617,6 +1618,11 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { * @return completion stage for the complete reward injection chain */ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) { + // A retained event options object can be used for a separate explicit dispatch. + // Only the original legacy invocation may update its terminal observer capture. + if (rewardOptions != null && !rewardOptions.claimLegacyFailureObserverCapture()) { + rewardOptions = rewardOptions.withoutLegacyFailureObserverCapture(); + } boolean primaryThread = false; try { primaryThread = Bukkit.isPrimaryThread(); @@ -1682,6 +1688,7 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R PlayerRewardEvent event = new PlayerRewardEvent(this, user, rewardOptions); Bukkit.getPluginManager().callEvent(event); + rewardOptions.updateLegacyFailureObserverCapture(); if (event.isCancelled()) { plugin.debug("Reward " + name + " was cancelled for " + user.getPlayerName()); return CompletableFuture.completedFuture(null); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java index 07f5e4a914..a97048239c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java @@ -4,6 +4,8 @@ import java.util.Map; import java.util.Map.Entry; import java.util.function.Consumer; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.simpleapi.array.ArrayUtils; @@ -106,6 +108,41 @@ public class RewardOptions { @Setter private Consumer asyncFailureObserver; + /** Per-legacy-dispatch handoff; never shared between separately requested rewards. */ + private LegacyFailureObserverCapture legacyFailureObserverCapture; + + private static final class LegacyFailureObserverCapture { + private final AtomicReference> observer; + private final AtomicBoolean claimed = new AtomicBoolean(); + private LegacyFailureObserverCapture(Consumer observer) { + this.observer = new AtomicReference<>(observer); + } + } + + RewardOptions captureLegacyFailureObserver() { + RewardOptions copy = copyForDispatch(); + copy.legacyFailureObserverCapture = new LegacyFailureObserverCapture(copy.asyncFailureObserver); + return copy; + } + + AtomicReference> legacyFailureObserverCapture() { + return legacyFailureObserverCapture == null ? null : legacyFailureObserverCapture.observer; + } + + boolean claimLegacyFailureObserverCapture() { + return legacyFailureObserverCapture == null || legacyFailureObserverCapture.claimed.compareAndSet(false, true); + } + + RewardOptions withoutLegacyFailureObserverCapture() { + RewardOptions copy = copyForDispatch(); + copy.legacyFailureObserverCapture = null; + return copy; + } + + void updateLegacyFailureObserverCapture() { + if (legacyFailureObserverCapture != null) legacyFailureObserverCapture.observer.set(asyncFailureObserver); + } + /** True while replaying an entry that must remain in the timed queue on deferral. */ @Getter @Setter @@ -280,6 +317,7 @@ RewardOptions copyForDispatch() { copy.setLegacyAsyncReplayCheckpoint(legacyAsyncReplayCheckpoint); copy.setAsyncReplayCheckpointConsumer(asyncReplayCheckpointConsumer); copy.setAsyncFailureObserver(asyncFailureObserver); + copy.legacyFailureObserverCapture = legacyFailureObserverCapture; copy.setAsyncReplayKey(asyncReplayKey); copy.setAsyncReplayOccurrenceId(asyncReplayOccurrenceId); copy.setTimedQueueReplay(timedQueueReplay); @@ -293,6 +331,7 @@ RewardOptions copyForDispatch() { */ RewardOptions copyForNestedDispatch(String replayKey) { RewardOptions copy = copyForDispatch(); + copy.legacyFailureObserverCapture = null; copy.setCompletedAsyncInjections(0); copy.setAsyncReplayProgress(new HashMap<>()); // The checkpoint consumer belongs to the queued parent occurrence. Nested 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 717ea9635c..5ab74844e5 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 @@ -156,13 +156,117 @@ void legacyFailureObserverRunsOnceForEligibilityBranchAndDoesNotMaskFailure() { AtomicInteger calls = new AtomicInteger(); AtomicReference seen = new AtomicReference<>(); options.setAsyncFailureObserver(failure -> { calls.incrementAndGet(); seen.set(failure); throw new IllegalStateException("observer"); }); - doReturn(CompletableFuture.failedFuture(original)).when(target).giveRewardAsync(user, options); + doReturn(CompletableFuture.failedFuture(original)).when(target).giveRewardAsync(eq(user), any(RewardOptions.class)); target.giveReward(user, options); assertEquals(1, calls.get()); assertSame(original, seen.get()); verify(plugin.getLogger()).log(eq(java.util.logging.Level.WARNING), any(String.class), eq(original)); } + @Test + void eventObserverOverridesCallerForOnlyThatLegacyDispatch() { + data.set("GameModes", List.of("SURVIVAL")); + var config = mock(AdvancedCoreConfigOptions.class); + when(plugin.getOptions()).thenReturn(config); + when(config.isProcessRewards()).thenReturn(true); + var manager = mock(org.bukkit.plugin.PluginManager.class); + AtomicInteger originalCalls = new AtomicInteger(), listenerCalls = new AtomicInteger(); + RewardOptions options = new RewardOptions().setCheckTimed(false).addPlaceholder("date", "fixed"); + options.setAsyncFailureObserver(failure -> originalCalls.incrementAndGet()); + doAnswer(invocation -> { + var event = invocation.getArgument(0, com.bencodez.advancedcore.listeners.PlayerRewardEvent.class); + event.getRewardOptions().setAsyncFailureObserver(failure -> listenerCalls.incrementAndGet()); + enabled.set(false); + return null; + }).when(manager).callEvent(any(com.bencodez.advancedcore.listeners.PlayerRewardEvent.class)); + try (var bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + reward.giveReward(user, options); + } + assertEquals(0, originalCalls.get()); + assertEquals(1, listenerCalls.get()); + assertNotNull(options.getAsyncFailureObserver(), "caller options remain unchanged"); + } + + @Test + void eventObserverIsCapturedBeforeTheDeferredDispatchContinues() { + data.set("GameModes", List.of("SURVIVAL")); + var config = mock(AdvancedCoreConfigOptions.class); + when(plugin.getOptions()).thenReturn(config); + when(config.isProcessRewards()).thenReturn(true); + var manager = mock(org.bukkit.plugin.PluginManager.class); + List queued = new ArrayList<>(); + doAnswer(invocation -> { queued.add(invocation.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + AtomicInteger callerCalls = new AtomicInteger(), eventCalls = new AtomicInteger(), lateCalls = new AtomicInteger(); + AtomicReference eventOptions = new AtomicReference<>(); + RewardOptions options = new RewardOptions().setCheckTimed(false).addPlaceholder("date", "fixed"); + options.setAsyncFailureObserver(failure -> callerCalls.incrementAndGet()); + doAnswer(invocation -> { + var event = invocation.getArgument(0, com.bencodez.advancedcore.listeners.PlayerRewardEvent.class); + eventOptions.set(event.getRewardOptions()); + event.getRewardOptions().setAsyncFailureObserver(failure -> eventCalls.incrementAndGet()); + return null; + }).when(manager).callEvent(any(com.bencodez.advancedcore.listeners.PlayerRewardEvent.class)); + try (var bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + reward.giveReward(user, options); + assertEquals(1, queued.size()); + options.setAsyncFailureObserver(failure -> lateCalls.incrementAndGet()); + eventOptions.get().setAsyncFailureObserver(failure -> lateCalls.incrementAndGet()); + enabled.set(false); + queued.get(0).run(); + } + assertEquals(0, callerCalls.get()); + assertEquals(1, eventCalls.get()); + assertEquals(0, lateCalls.get()); + } + + @Test + void explicitChildUsingRetainedEventOptionsCannotReplacePendingParentObserver() throws Exception { + data.set("GameModes", List.of("SURVIVAL")); + var config = mock(AdvancedCoreConfigOptions.class); + when(plugin.getOptions()).thenReturn(config); + when(config.isProcessRewards()).thenReturn(true); + Reward child = new Reward("Child", data); + var pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(child, plugin); + var manager = mock(org.bukkit.plugin.PluginManager.class); + List queued = new ArrayList<>(); + doAnswer(invocation -> { queued.add(invocation.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + AtomicInteger parentCalls = new AtomicInteger(), childCalls = new AtomicInteger(); + AtomicReference retained = new AtomicReference<>(); + RewardOptions options = new RewardOptions().setCheckTimed(false).addPlaceholder("date", "fixed"); + doAnswer(invocation -> { + var event = invocation.getArgument(0, com.bencodez.advancedcore.listeners.PlayerRewardEvent.class); + if (event.getReward() == reward) { + retained.set(event.getRewardOptions()); + event.getRewardOptions().setAsyncFailureObserver(failure -> parentCalls.incrementAndGet()); + } else if (event.getReward() == child) { + event.getRewardOptions().setAsyncFailureObserver(failure -> childCalls.incrementAndGet()); + } + return null; + }).when(manager).callEvent(any(com.bencodez.advancedcore.listeners.PlayerRewardEvent.class)); + try (var bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + reward.giveReward(user, options); + assertEquals(1, queued.size()); + CompletionStage childResult = child.giveRewardAsync(user, retained.get()); + assertEquals(2, queued.size()); + enabled.set(false); + queued.get(0).run(); + queued.get(1).run(); + assertThrows(CompletionException.class, () -> childResult.toCompletableFuture().join()); + } + assertEquals(1, parentCalls.get()); + assertEquals(0, childCalls.get()); + } + @Test void legacyInjectionFailureObserverDoesNotChangeExplicitAsyncFailure() { RewardInject async = mock(RewardInject.class); @@ -230,13 +334,25 @@ void failureObserverSurvivesDispatchAndNestedOptionCopies() throws Exception { assertSame(observer, dispatchCopy.getAsyncFailureObserver()); assertSame(observer, nestedCopy.getAsyncFailureObserver()); assertNull(new RewardOptions().getAsyncFailureObserver()); + var capture = RewardOptions.class.getDeclaredMethod("captureLegacyFailureObserver"); capture.setAccessible(true); + var holder = RewardOptions.class.getDeclaredMethod("legacyFailureObserverCapture"); holder.setAccessible(true); + var update = RewardOptions.class.getDeclaredMethod("updateLegacyFailureObserverCapture"); update.setAccessible(true); + RewardOptions tracked = (RewardOptions) capture.invoke(options); + RewardOptions child = (RewardOptions) nested.invoke(tracked, "parent/child"); + child.setAsyncFailureObserver(failure -> { }); + update.invoke(child); + assertSame(observer, ((AtomicReference) holder.invoke(tracked)).get(), + "nested options must not replace the parent's terminal observer"); } @Test - void legacyListFanoutNotifiesOncePerConcreteFailure() { + void legacyListFanoutNotifiesOncePerConcreteFailure() throws Exception { data.set("GameModes", List.of("SURVIVAL")); Reward first = org.mockito.Mockito.spy(reward); Reward second = org.mockito.Mockito.spy(new Reward("Second", data)); + var pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(second, plugin); var firstFailure = new IllegalStateException("first"); var secondFailure = new IllegalStateException("second"); doReturn(CompletableFuture.failedFuture(firstFailure)).when(first).giveRewardAsync(eq(user), any(RewardOptions.class));